Compare commits

..
2 Commits
7 changed files with 767 additions and 393 deletions

No files matched your search

+1
View File
@@ -8,6 +8,7 @@ const ALLOWED_KEYS = new Set([
'chart_interets', 'chart_interets',
'chart_capital', 'chart_capital',
'chart_cashback', 'chart_cashback',
'pfo_assujetti',
// Extensible : ajouter ici les futures prefs (theme, font_scale, langue, devise, display_mode…) // Extensible : ajouter ici les futures prefs (theme, font_scale, langue, devise, display_mode…)
]); ]);
+150 -7
View File
@@ -14,6 +14,19 @@ function hexToRgba(hex, a) {
const round2 = v => Math.round(v * 100) / 100; const round2 = v => Math.round(v * 100) / 100;
/* Petite bulle de couleur — indicateur vert/orange/gris à côté du nom du projet */
function Bubble({ color, title }) {
return (
<span
title={title}
style={{
display: 'inline-block', width: 8, height: 8, borderRadius: '50%',
background: color, marginLeft: 7, flex: '0 0 auto',
}}
/>
);
}
/** /**
* DrillCellPanel * DrillCellPanel
* *
@@ -45,6 +58,7 @@ export default function DrillCellPanel({
} = useInteretsChart(); } = useInteretsChart();
const [data, setData] = useState(null); const [data, setData] = useState(null);
const [prevData, setPrevData] = useState(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
/* filterPlatId : '' = toutes, sinon l'id (string) de la plateforme sélectionnée dans le dropdown */ /* filterPlatId : '' = toutes, sinon l'id (string) de la plateforme sélectionnée dans le dropdown */
const [filterPlatId, setFilterPlatId] = useState(''); const [filterPlatId, setFilterPlatId] = useState('');
@@ -57,6 +71,7 @@ export default function DrillCellPanel({
const [bulkProcessing, setBulkProcessing] = useState(false); const [bulkProcessing, setBulkProcessing] = useState(false);
const [bulkProgress, setBulkProgress] = useState(0); const [bulkProgress, setBulkProgress] = useState(0);
const [bulkDone, setBulkDone] = useState(false); const [bulkDone, setBulkDone] = useState(false);
const [bulkErrors, setBulkErrors] = useState([]);
/* ── Reset filtres quand la cellule change ── */ /* ── Reset filtres quand la cellule change ── */
useEffect(() => { useEffect(() => {
@@ -94,6 +109,32 @@ export default function DrillCellPanel({
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [cell?.annee, cell?.mois, filterPlatId, activeView, activeId, refreshKey]); }, [cell?.annee, cell?.mois, filterPlatId, activeView, activeId, refreshKey]);
/* ── Fetch du mois précédent (pour l'indicateur vert/orange/gris) ── */
useEffect(() => {
if (!cell) { setPrevData(null); return; }
const isInitialFilter = cell.platId ? filterPlatId === String(cell.platId) : !filterPlatId;
const consolidatedIds = isInitialFilter && cell.platIds?.length > 1
? cell.platIds.join(',')
: null;
let prevMois = cell.mois - 1;
let prevAnnee = cell.annee;
if (prevMois < 1) { prevMois = 12; prevAnnee -= 1; }
const params = {
annee: prevAnnee,
mois: prevMois,
...(consolidatedIds
? { plateforme_ids: consolidatedIds }
: filterPlatId ? { plateforme_id: filterPlatId } : {}),
...(activeView === 'all' ? { scope: 'all' } : {}),
};
api.get('/dashboard/detail-cellule', params)
.then(d => setPrevData(d))
.catch(() => setPrevData({ recus: [], projetes: [] }));
}, [cell?.annee, cell?.mois, filterPlatId, activeView, activeId, refreshKey]);
/* ── Taux PFU pour l'année de la cellule ── */ /* ── Taux PFU pour l'année de la cellule ── */
const pfuReduction = useMemo(() => { const pfuReduction = useMemo(() => {
if (!pfuRates?.length || !cell) return 0; if (!pfuRates?.length || !cell) return 0;
@@ -113,6 +154,22 @@ export default function DrillCellPanel({
return [...data.projetes].sort((a, b) => (a.date_prevue || '').localeCompare(b.date_prevue || '')); return [...data.projetes].sort((a, b) => (a.date_prevue || '').localeCompare(b.date_prevue || ''));
}, [data]); }, [data]);
/* ── Agrégation des reçus du mois précédent, par investissement ──
Sert de référence pour l'indicateur vert/orange/gris à côté du nom du projet. */
const prevRecusByInv = useMemo(() => {
const map = new Map();
if (!prevData) return map;
for (const r of prevData.recus) {
const acc = map.get(r.investissement_id) || { interets_bruts: 0, interets_nets: 0, capital: 0, cashback: 0 };
acc.interets_bruts += r.interets_bruts || 0;
acc.interets_nets += r.interets_nets || 0;
acc.capital += r.capital || 0;
acc.cashback += r.cashback || 0;
map.set(r.investissement_id, acc);
}
return map;
}, [prevData]);
if (!cell) return null; if (!cell) return null;
/* ── Afficher la colonne Plateforme quand on est en mode "toutes" ── */ /* ── Afficher la colonne Plateforme quand on est en mode "toutes" ── */
@@ -136,6 +193,36 @@ export default function DrillCellPanel({
return v; return v;
}; };
/* ── Valeur du mois précédent pour un investissement (mêmes critères actifs) ──
undefined = aucun remboursement reçu ce mois-là pour cet investissement */
const prevRecuValue = (investissementId) => {
const acc = prevRecusByInv.get(investissementId);
if (!acc) return undefined;
let v = 0;
if (inclureInterets) v += netMode ? acc.interets_nets : acc.interets_bruts;
if (inclureCashback) v += acc.cashback;
if (inclureCapital) v += acc.capital;
return v;
};
/* ── Indicateur visuel (bulle) — reçus : vert si ≥ mois précédent ou si rien n'existait, orange sinon ── */
const recuIndicator = (r) => {
const prev = prevRecuValue(r.investissement_id);
const cur = recuRowValue(r);
if (prev === undefined) return { color: 'var(--success)', title: 'Aucun remboursement le mois précédent' };
if (round2(cur) >= round2(prev)) return { color: 'var(--success)', title: `≥ mois précédent (${fmtEUR(prev)})` };
return { color: 'var(--warning)', title: `< mois précédent (${fmtEUR(prev)})` };
};
/* ── Indicateur visuel (bulle) — projetés : vert si = reçu du mois précédent, gris si rien n'existait, orange sinon ── */
const projIndicator = (p) => {
const prev = prevRecuValue(p.investissement_id);
const cur = projRowValue(p);
if (prev === undefined) return { color: 'var(--text-muted)', title: 'Aucun remboursement le mois précédent' };
if (round2(cur) === round2(prev)) return { color: 'var(--success)', title: `= mois précédent (${fmtEUR(prev)})` };
return { color: 'var(--warning)', title: `≠ mois précédent (${fmtEUR(prev)})` };
};
/* ── Totaux ── */ /* ── Totaux ── */
const totalRecus = recus.reduce((s, r) => s + recuRowValue(r), 0); const totalRecus = recus.reduce((s, r) => s + recuRowValue(r), 0);
const totalProjetes = projetes.reduce((s, p) => s + projRowValue(p), 0); const totalProjetes = projetes.reduce((s, p) => s + projRowValue(p), 0);
@@ -195,7 +282,7 @@ export default function DrillCellPanel({
statut: 'paye', statut: 'paye',
notes: '', notes: '',
methode_remboursement: methode, methode_remboursement: methode,
compte_id: methode === 'compte_courant' ? (inv?.compte_id || '') : '', compte_id: methode === 'compte_courant' && inv?.compte_id ? inv.compte_id : null,
}; };
}); });
setBulkItems(items); setBulkItems(items);
@@ -208,12 +295,18 @@ export default function DrillCellPanel({
const runBulk = async () => { const runBulk = async () => {
setBulkProcessing(true); setBulkProcessing(true);
let done = 0; let done = 0;
const errors = [];
for (const item of bulkItems) { for (const item of bulkItems) {
const { _label, _plat, _date, _capital, _interets, _total, ...payload } = item; const { _label, _plat, _date, _capital, _interets, _total, ...payload } = item;
try { await api.post('/remboursements', payload); } catch (e) { /* continuer */ } try {
await api.post('/remboursements', payload);
} catch (e) {
errors.push({ label: _label, plat: _plat, date: _date, message: e.message });
}
done++; done++;
setBulkProgress(done); setBulkProgress(done);
} }
setBulkErrors(errors);
setBulkProcessing(false); setBulkProcessing(false);
setBulkDone(true); setBulkDone(true);
if (onBulkDone) onBulkDone(); if (onBulkDone) onBulkDone();
@@ -225,6 +318,7 @@ export default function DrillCellPanel({
setBulkProgress(0); setBulkProgress(0);
setBulkDone(false); setBulkDone(false);
setBulkProcessing(false); setBulkProcessing(false);
setBulkErrors([]);
}; };
/* ── Rendu ── */ /* ── Rendu ── */
@@ -356,7 +450,12 @@ export default function DrillCellPanel({
> >
<td style={tdStyle}>{fmtDate(r.date_remb)}</td> <td style={tdStyle}>{fmtDate(r.date_remb)}</td>
{showPlatCol && <td style={{ ...tdStyle, fontWeight: 500 }}>{r.plateforme_nom || '—'}</td>} {showPlatCol && <td style={{ ...tdStyle, fontWeight: 500 }}>{r.plateforme_nom || '—'}</td>}
<td style={tdStyle}>{r.nom_projet}</td> <td style={tdStyle}>
<span style={{ display: 'inline-flex', alignItems: 'center' }}>
{r.nom_projet}
<Bubble {...recuIndicator(r)} />
</span>
</td>
<td style={{ ...tdStyle, color: 'var(--text-muted)', fontSize: '0.8em' }}>{r.detenteur_nom || '—'}</td> <td style={{ ...tdStyle, color: 'var(--text-muted)', fontSize: '0.8em' }}>{r.detenteur_nom || '—'}</td>
{cols.map(c => { {cols.map(c => {
let v; let v;
@@ -434,7 +533,12 @@ export default function DrillCellPanel({
> >
<td style={{ ...tdStyle, fontStyle: 'italic', color: 'var(--text-muted)' }}>{fmtDate(p.date_prevue)}</td> <td style={{ ...tdStyle, fontStyle: 'italic', color: 'var(--text-muted)' }}>{fmtDate(p.date_prevue)}</td>
{showPlatCol && <td style={{ ...tdStyle, fontStyle: 'italic', fontWeight: 500 }}>{p.plateforme_nom || '—'}</td>} {showPlatCol && <td style={{ ...tdStyle, fontStyle: 'italic', fontWeight: 500 }}>{p.plateforme_nom || '—'}</td>}
<td style={{ ...tdStyle, fontStyle: 'italic' }}>{p.nom_projet}</td> <td style={{ ...tdStyle, fontStyle: 'italic' }}>
<span style={{ display: 'inline-flex', alignItems: 'center' }}>
{p.nom_projet}
<Bubble {...projIndicator(p)} />
</span>
</td>
<td style={{ ...tdStyle, color: 'var(--text-muted)', fontSize: '0.8em' }}>{p.detenteur_nom || '—'}</td> <td style={{ ...tdStyle, color: 'var(--text-muted)', fontSize: '0.8em' }}>{p.detenteur_nom || '—'}</td>
{cols.map(c => { {cols.map(c => {
let v; let v;
@@ -529,21 +633,60 @@ export default function DrillCellPanel({
</div> </div>
</div> </div>
)} )}
{bulkDone && ( {bulkDone && (() => {
const successCount = bulkItems.length - bulkErrors.length;
const allOk = bulkErrors.length === 0;
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 10, padding: '12px 0' }}> <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 10, padding: '12px 0' }}>
<div style={{ width: 48, height: 48, borderRadius: '50%', background: 'var(--success)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}> <div style={{
width: 48, height: 48, borderRadius: '50%',
background: allOk ? 'var(--success)' : 'var(--warning)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
{allOk ? (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"> <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12"/> <polyline points="20 6 9 17 4 12"/>
</svg> </svg>
) : (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<line x1="12" y1="8" x2="12" y2="13"/><line x1="12" y1="16.5" x2="12.01" y2="16.5"/>
<circle cx="12" cy="12" r="10"/>
</svg>
)}
</div> </div>
<p style={{ margin: 0, fontWeight: 600, fontSize: '1rem' }}> <p style={{ margin: 0, fontWeight: 600, fontSize: '1rem' }}>
{bulkItems.length} remboursement{bulkItems.length > 1 ? 's' : ''} enregistré{bulkItems.length > 1 ? 's' : ''} {allOk
? `${bulkItems.length} remboursement${bulkItems.length > 1 ? 's' : ''} enregistré${bulkItems.length > 1 ? 's' : ''}`
: `${successCount}/${bulkItems.length} remboursement${bulkItems.length > 1 ? 's' : ''} enregistré${successCount > 1 ? 's' : ''} — ${bulkErrors.length} échec${bulkErrors.length > 1 ? 's' : ''}`}
</p> </p>
<p style={{ margin: 0, color: 'var(--text-muted)', fontSize: '0.88rem' }}> <p style={{ margin: 0, color: 'var(--text-muted)', fontSize: '0.88rem' }}>
{cell.moisLabel} {cell.annee} {cell.moisLabel} {cell.annee}
</p> </p>
{!allOk && (
<div style={{ width: '100%', marginTop: 8, border: '1px solid var(--danger)', borderRadius: 6, overflow: 'hidden' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.82rem' }}>
<thead>
<tr style={{ background: 'var(--surface-2)' }}>
<th style={{ ...thStyle, color: 'var(--danger)' }}>Projet</th>
<th style={{ ...thStyle, color: 'var(--danger)' }}>Date</th>
<th style={{ ...thStyle, color: 'var(--danger)' }}>Erreur</th>
</tr>
</thead>
<tbody>
{bulkErrors.map((e, i) => (
<tr key={i} style={{ borderTop: '1px solid var(--border)' }}>
<td style={tdStyle}>{e.plat ? `${e.plat} — ` : ''}{e.label}</td>
<td style={tdStyle}>{fmtDate(e.date)}</td>
<td style={{ ...tdStyle, color: 'var(--danger)' }}>{e.message}</td>
</tr>
))}
</tbody>
</table>
</div> </div>
)} )}
</div>
);
})()}
{!bulkDone && ( {!bulkDone && (
<> <>
<p style={{ margin: '0 0 12px', color: 'var(--text-muted)', fontSize: '0.88rem' }}> <p style={{ margin: '0 0 12px', color: 'var(--text-muted)', fontSize: '0.88rem' }}>
+288
View File
@@ -0,0 +1,288 @@
import Modal from './Modal.jsx';
import { fmtEUR } from '../utils/format.js';
/* ── Helpers purs (dupliqués volontairement, pas d'état) ── */
const round2 = (n) => Math.round(n * 100) / 100;
const fmtRate = (n) => n != null ? n.toFixed(1).replace('.', ',') + ' %' : '…';
const computeInteretsNets = (f) =>
round2(Number(f.interets_bruts || 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));
const getRatesForYear = (dateStr, pfuRates) => {
if (!dateStr || !pfuRates?.length) return null;
const year = parseInt(dateStr.slice(0, 4), 10);
return pfuRates.find(r => r.annee === year)
?? pfuRates.reduce((best, r) => r.annee > best.annee ? r : best, pfuRates[0]);
};
const BONUS_VALUES = ['BONUS_PARRAINAGE', 'BONUS_PLATEFORME'];
/**
* RembFormModal — formulaire partagé de saisie/édition d'un remboursement.
*
* Utilisé par Remboursements.jsx (mode complet, sélecteur d'investissement + bonus),
* InvestissementDetail.jsx et Plateformes.jsx (mode `locked` : investissement déjà déterminé
* par le contexte, pas de sélecteur ni de bonus).
*
* Le composant est 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,
* héritage de la méthode de remboursement, calcul de la fiscalité locale…). Ce composant se
* contente de dériver les valeurs d'affichage (labels PFU, fiscalité indicative, montant versé…)
* et de restituer le JSX.
*
* Props :
* open, onClose — état d'ouverture
* editingId — id en édition, null si création
* title — titre optionnel (sinon déduit de editingId/locked)
* form — objet form courant (voir `empty` dans les pages appelantes)
* onFieldChange(key, value) — pour les champs à effets de bord (déléqué au setField du parent)
* onFormPatch(partialObj) — pour les mises à jour directes sans effet de bord (setForm({...}))
* err — message d'erreur à afficher
* onSubmit — handler de soumission du formulaire
* onDelete — handler de suppression (optionnel, affiche le bouton si fourni)
* pfuRates — taux PFU par année
* comptesInvestisseur — comptes du détenteur courant (pour le select compte de réception)
* investissements — liste des investissements (pour résoudre l'investissement courant
* à partir de form.investissement_id). En mode locked, passer [investissement].
* plateformes — liste des plateformes (fiscalité locale, méthode héritée, dropdown bonus)
* locked — bool : masque le sélecteur d'investissement et les champs bonus
* investissementOptions — liste pour le <select> investissement (mode non-locked uniquement)
* multiDetenteur — bool, affiche le détenteur dans le dropdown bonus (mode non-locked)
* confirmingDelete, setConfirmingDelete — état de confirmation de suppression (géré par le parent,
* remis à false dans son propre onClose)
*/
export default function RembFormModal({
open, onClose, editingId, title,
form, onFieldChange, onFormPatch,
err, onSubmit, onDelete,
pfuRates, comptesInvestisseur,
investissements = [], plateformes = [],
locked = false,
investissementOptions = [],
multiDetenteur = false,
confirmingDelete, setConfirmingDelete,
}) {
const isBonus = !locked && BONUS_VALUES.includes(form.investissement_id);
const rates = getRatesForYear(form.date_remb, pfuRates);
const labelPS = `Prélèvements sociaux${rates ? ` (${fmtRate(rates.prelev_sociaux)})` : ''} (€)`;
const labelIR = `Impôt sur le revenu${rates ? ` (${fmtRate(rates.impot_revenu)})` : ''} (€)`;
const interetsNets = computeInteretsNets(form);
const currentInv = locked
? investissements[0]
: (!isBonus ? investissements.find(i => i.id === Number(form.investissement_id)) : null);
const currentPlat = currentInv ? plateformes.find(p => p.id === currentInv.plateforme_id) : null;
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'));
const montantVerse = isIndicatif
? round2(Number(form.capital || 0) + Number(form.cashback || 0) + Number(form.interets_bruts || 0))
: computeNet(form);
const modalTitle = title ?? (editingId ? 'Modifier le remboursement' : (locked ? 'Saisir le remboursement' : 'Nouveau remboursement'));
const handleMethodeChange = (m) => {
const def = m === 'compte_courant' ? (comptesInvestisseur?.find(c => c.type === 'compte_courant') ?? comptesInvestisseur?.[0]) : null;
onFormPatch({ methode_remboursement: m, compte_id: def ? String(def.id) : '' });
};
return (
<Modal
open={open}
title={modalTitle}
onClose={onClose}
width={900}
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 onClick={onClose}>Annuler</button>
<button className="primary" onClick={onSubmit}>{editingId ? 'Enregistrer' : 'Créer'}</button>
</div>
}
>
<form onSubmit={onSubmit}>
{err && <div className="error">{err}</div>}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '12px 16px' }}>
{/* ── Sélecteur d'investissement (mode non verrouillé uniquement) ── */}
{!locked && (
<div style={{ gridColumn: isBonus ? 'span 1' : 'span 3' }}>
<label>Investissement *</label>
<select required value={form.investissement_id}
onChange={e => onFieldChange('investissement_id', e.target.value)}>
<option value="">—</option>
<option value="BONUS_PARRAINAGE">🎁 Bonus Parrainage</option>
<option value="BONUS_PLATEFORME">🏆 Bonus Plateforme</option>
<optgroup label="──────────────────" />
{investissementOptions.map(i =>
<option key={i.id} value={i.id}>{i.plateforme_nom} — {i.nom_projet} ({fmtEUR(i.montant_investi)})</option>
)}
</select>
</div>
)}
{!locked && isBonus && (
<div style={{ gridColumn: 'span 2' }}>
<label>Plateforme associée (bonus)</label>
<select value={form.bonus_plateforme_id} onChange={e => {
const plat = plateformes.find(p => String(p.id) === e.target.value);
onFormPatch({ bonus_plateforme_id: e.target.value, bonus_investisseur_id: plat?.investisseur_id ? String(plat.investisseur_id) : '' });
}}>
<option value="">— Aucune —</option>
{plateformes.map(p => <option key={p.id} value={p.id}>{p.nom}{multiDetenteur && p.investisseur_nom ? ` — ${p.investisseur_nom}` : ''}</option>)}
</select>
</div>
)}
{/* ── Champs remboursement normal ── */}
{!isBonus && (<>
<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)' }}>Remboursement</span>
</div>
{hasLocalTax && (
<div style={{ gridColumn: 'span 2' }}>
<label>Intérêts bruts avant retenue locale ({currentPlat.taux_fiscalite_locale} %) (€)</label>
<input type="number" step="0.01" value={form.interets_bruts_avant_local ?? 0}
onChange={e => onFieldChange('interets_bruts_avant_local', e.target.value)} />
</div>
)}
{hasLocalTax && (
<div>
<label>Retenue à la source locale (€)</label>
<input type="number" step="0.01" value={form.taxe_locale ?? 0}
onChange={e => onFieldChange('taxe_locale', e.target.value)} />
</div>
)}
<div>
<label>Capital (€)</label>
<input type="number" step="0.01" value={form.capital ?? 0}
onChange={e => onFieldChange('capital', e.target.value)} />
</div>
<div>
<label>{hasLocalTax ? 'Intérêts bruts après retenue locale (€)' : 'Intérêts bruts (€)'}</label>
<input type="number" step="0.01" value={form.interets_bruts ?? 0}
readOnly={!!hasLocalTax}
style={hasLocalTax ? { background: 'var(--surface-2)', color: 'var(--text-muted)', cursor: 'not-allowed' } : undefined}
onChange={e => !hasLocalTax && onFieldChange('interets_bruts', e.target.value)} />
</div>
<div>
<label>Cashback (€)</label>
<input type="number" step="0.01" value={form.cashback ?? 0}
onChange={e => onFieldChange('cashback', e.target.value)} />
</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>
<div>
<label>{labelPS}</label>
<input type="number" step="0.01" value={form.prelev_sociaux ?? 0}
readOnly={isIndicatif}
style={isIndicatif ? { background: 'var(--surface-2)', color: 'var(--text-muted)', cursor: 'not-allowed' } : undefined}
onChange={e => !isIndicatif && onFormPatch({ prelev_sociaux: Number(e.target.value) })} />
</div>
<div>
<label>{labelIR}</label>
<input type="number" step="0.01" value={form.prelev_forfaitaire ?? 0}
readOnly={isIndicatif}
style={isIndicatif ? { background: 'var(--surface-2)', color: 'var(--text-muted)', cursor: 'not-allowed' } : undefined}
onChange={e => !isIndicatif && onFormPatch({ prelev_forfaitaire: Number(e.target.value) })} />
</div>
<div>
<label>Total prélèvements (€)</label>
<input type="number" step="0.01"
value={round2((Number(form.prelev_sociaux) || 0) + (Number(form.prelev_forfaitaire) || 0))}
readOnly
style={{ background: 'var(--surface-2)', color: 'var(--text-muted)', cursor: 'not-allowed' }} />
</div>
<div style={{ gridColumn: 'span 3', display: 'flex', alignItems: 'center', gap: 8, fontSize: 13 }}>
<span style={{ color: 'var(--text-muted)' }}>Montant des intérêts après imposition</span>
<span style={{ fontWeight: 600, color: 'var(--text)' }}>{fmtEUR(interetsNets)}</span>
</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)' }}>Versement</span>
</div>
<div>
<label>Date *</label>
<input type="date" required value={form.date_remb || ''}
onChange={e => onFieldChange('date_remb', e.target.value)} />
</div>
<div>
<label>Méthode de remboursement</label>
<select value={form.methode_remboursement || 'portefeuille'}
onChange={e => handleMethodeChange(e.target.value)}>
<option value="portefeuille">Porte-monnaie de la plateforme</option>
<option value="compte_courant">Compte courant de l'investisseur</option>
</select>
</div>
<div>
{form.methode_remboursement === 'compte_courant' && (
<>
<label>Compte de réception</label>
{comptesInvestisseur?.length > 0 ? (
<select value={form.compte_id || ''}
onChange={e => onFormPatch({ 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>
)}
</>
)}
<label>Montant versé (€)</label>
<input type="number" step="0.01" value={montantVerse} readOnly
style={{ background: 'var(--surface-2)', color: 'var(--text-muted)', cursor: 'not-allowed' }} />
</div>
<div style={{ gridColumn: 'span 3', display: 'flex', alignItems: 'center', gap: 8, fontSize: 13 }}>
<span style={{ color: 'var(--text-muted)' }}>
{isIndicatif
? "L'imposition n'est pas déduite du montant versé (fiscalité appliquée à titre indicatif)."
: "L'imposition est déduite du montant versé (capital + cashback + intérêts nets)."}
</span>
</div>
</>)}
{/* ── Bonus (mode non verrouillé uniquement) ── */}
{!locked && isBonus && (
<div>
<label>Date *</label>
<input type="date" required value={form.date_remb || ''}
onChange={e => onFieldChange('date_remb', e.target.value)} />
</div>
)}
{!locked && isBonus && (
<div style={{ gridColumn: 'span 2' }}>
<label>Montant (€)</label>
<input type="number" step="0.01" value={form.cashback ?? 0}
onChange={e => onFieldChange('cashback', e.target.value)} />
</div>
)}
</div>
</form>
</Modal>
);
}
+25 -6
View File
@@ -424,6 +424,8 @@ export default function Dashboard() {
const [capitalMensuelData, setCapitalMensuelData] = useState([]); const [capitalMensuelData, setCapitalMensuelData] = useState([]);
const [plateformes, setPlateformes] = useState([]); const [plateformes, setPlateformes] = useState([]);
const [platLoaded, setPlatLoaded] = useState(false); const [platLoaded, setPlatLoaded] = useState(false);
const [investissements, setInvestissements] = useState([]);
const [drillRefreshKey, setDrillRefreshKey] = useState(0);
/* ── drillCell : cellule sélectionnée dans le TIP — par défaut mois courant toutes plateformes ── */ /* ── drillCell : cellule sélectionnée dans le TIP — par défaut mois courant toutes plateformes ── */
const _now = new Date(); const _now = new Date();
@@ -466,14 +468,30 @@ export default function Dashboard() {
api.get('/plateformes').then(d => { setPlateformes(d); setPlatLoaded(true); }).catch(() => { setPlatLoaded(true); }); api.get('/plateformes').then(d => { setPlateformes(d); setPlatLoaded(true); }).catch(() => { setPlatLoaded(true); });
}, []); }, []);
const loadDashboardData = () => {
if (!activeId && activeView !== 'all') return Promise.resolve();
const scopeParams = activeView === 'all' ? { scope: 'all' } : undefined;
return Promise.all([
api.get('/dashboard', scopeParams).then(setData),
api.get('/investissements', scopeParams).then(setInvestissements).catch(() => setInvestissements([])),
]);
};
useEffect(() => { useEffect(() => {
if (!activeId && activeView !== 'all') return;
setData(null); setData(null);
setLoading(true); setLoading(true);
const params = activeView === 'all' ? { scope: 'all' } : undefined; loadDashboardData().finally(() => setLoading(false));
api.get('/dashboard', params).then(setData).finally(() => setLoading(false)); /* eslint-disable-next-line */
}, [activeView, activeId]); }, [activeView, activeId]);
/* Rafraîchit le tableau de bord + le panneau de détail après une validation en masse
de projections (bulk) depuis le DrillCellPanel — sans quoi l'écran restait figé sur
les anciennes projections malgré des remboursements bien créés en base. */
const onDrillBulkDone = () => {
loadDashboardData();
setDrillRefreshKey(k => k + 1);
};
const ready = activeView === 'all' ? investisseurs.length > 0 : !!activeId; const ready = activeView === 'all' ? investisseurs.length > 0 : !!activeId;
if (!ready) return <div className="card text-muted">Sélectionnez un compte investisseur.</div>; if (!ready) return <div className="card text-muted">Sélectionnez un compte investisseur.</div>;
if (loading || !data) return <div className="card text-muted">Chargement…</div>; if (loading || !data) return <div className="card text-muted">Chargement…</div>;
@@ -505,7 +523,7 @@ export default function Dashboard() {
<> <>
<div className="topbar"><h2><PageIcon name="dashboard" />Tableau de bord — {viewTitle}</h2></div> <div className="topbar"><h2><PageIcon name="dashboard" />Tableau de bord — {viewTitle}</h2></div>
<InteretsChartProvider netMode={netMode} pfuRates={pfuRates} activeView={activeView} activeId={activeId}> <InteretsChartProvider key={drillRefreshKey} netMode={netMode} pfuRates={pfuRates} activeView={activeView} activeId={activeId}>
{/* ── KPI + sélecteur année ── */} {/* ── KPI + sélecteur année ── */}
<div style={{ display: 'flex', gap: 12, alignItems: 'stretch', marginBottom: 16 }}> <div style={{ display: 'flex', gap: 12, alignItems: 'stretch', marginBottom: 16 }}>
@@ -545,8 +563,9 @@ export default function Dashboard() {
activeView={activeView} activeView={activeView}
activeId={activeId} activeId={activeId}
plateformes={plateformes} plateformes={plateformes}
investissements={[]} investissements={investissements}
onBulkDone={() => {}} refreshKey={drillRefreshKey}
onBulkDone={onDrillBulkDone}
onEditRecu={(r) => { onEditRecu={(r) => {
const q = new URLSearchParams({ const q = new URLSearchParams({
'edit-remb': r.id, 'edit-remb': r.id,
+17 -169
View File
@@ -5,6 +5,7 @@ import { useInvestisseur } from '../context/InvestisseurContext.jsx';
import { useUi } from '../context/UiContext.jsx'; 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 CountrySelect, { COUNTRIES, FlagIcon } from '../components/CountrySelect.jsx'; import CountrySelect, { COUNTRIES, FlagIcon } from '../components/CountrySelect.jsx';
import { fmtEUR, fmtPct, fmtDate, today } from '../utils/format.js'; import { fmtEUR, fmtPct, fmtDate, today } from '../utils/format.js';
import { xirr } from '../utils/xirr.js'; import { xirr } from '../utils/xirr.js';
@@ -2194,177 +2195,24 @@ export default function InvestissementDetail() {
</Modal> </Modal>
{/* ── Modal saisie remboursement (depuis projection) ────────── */} {/* ── Modal saisie remboursement (depuis projection) ────────── */}
{(() => { <RembFormModal
const rates = getRatesForYear(rembForm.date_remb);
const fmtRate = n => n != null ? n.toFixed(1).replace('.', ',') + ' %' : '…';
const labelPS = `Prélèvements sociaux${rates ? ` (${fmtRate(rates.prelev_sociaux)})` : ''} (€)`;
const currentPlat = plats.find(p => p.id === inv?.plateforme_id);
const isChoixOuvert = currentPlat?.methode_remboursement === 'choix_investisseur';
const hasLocalTax = currentPlat?.fiscalite === 'avec_fiscalite_locale' && currentPlat?.taux_fiscalite_locale;
const labelIR = `Impôt sur le revenu${rates ? ` (${fmtRate(rates.impot_revenu)})` : ''} (€)`;
const interetsNets = computeInteretsNets(rembForm);
const netRecu = computeNet(rembForm);
const isExonere = inv?.fiscalite_override === 'exonere';
// Indicatif = plateforme étrangère (non flat_tax) OU investissement exonéré PEA-PME.
// Dans ce cas, les prélèvements sont affichés en lecture seule et le montant versé
// ne les déduit pas (l'investisseur reçoit le brut intégral).
const isIndicatif = isExonere || currentPlat?.fiscalite !== 'flat_tax';
const montantRembourse = isIndicatif
? round2((Number(rembForm.capital)||0) + (Number(rembForm.cashback)||0) + (Number(rembForm.interets_bruts)||0))
: netRecu;
return (
<Modal
open={rembModalOpen} open={rembModalOpen}
title={editingRembId ? 'Modifier le remboursement' : 'Saisir le remboursement'} editingId={editingRembId}
onClose={closeRembModal} onClose={closeRembModal}
width={900} onSubmit={submitRemb}
footer={ onDelete={deleteRemb}
<div style={{ display: 'flex', width: '100%', justifyContent: 'space-between', alignItems: 'center' }}> confirmingDelete={confirmingRembDelete}
{editingRembId && !confirmingRembDelete && ( setConfirmingDelete={setConfirmingRembDelete}
<button className="danger" onClick={() => setConfirmingRembDelete(true)}> err={rembErr}
Supprimer form={rembForm}
</button> onFieldChange={setRembField}
)} onFormPatch={patch => setRembForm(f => ({ ...f, ...patch }))}
{confirmingRembDelete && ( pfuRates={pfuRates}
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}> comptesInvestisseur={comptesRembInvestisseur}
<span style={{ fontSize: 13, color: 'var(--danger, #ef4444)' }}> investissements={inv ? [inv] : []}
Supprimer ce remboursement ? plateformes={plats}
</span> locked
<button onClick={() => setConfirmingRembDelete(false)}>Non</button> />
<button className="danger" onClick={deleteRemb}>Oui, supprimer</button>
</div>
)}
{!confirmingRembDelete && (
<div style={{ display: 'flex', gap: 8, marginLeft: 'auto' }}>
<button onClick={closeRembModal}>Annuler</button>
<button className="primary" onClick={submitRemb}>Enregistrer</button>
</div>
)}
</div>
}
>
<form onSubmit={submitRemb}>
{rembErr && <div className="error">{rembErr}</div>}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '12px 16px' }}>
<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)' }}>Remboursement</span>
</div>
{hasLocalTax && (
<div style={{ gridColumn: 'span 2' }}>
<label>Intérêts bruts avant retenue locale ({currentPlat.taux_fiscalite_locale} %) (€)</label>
<input type="number" step="0.01" value={rembForm.interets_bruts_avant_local ?? 0}
onChange={e => setRembField('interets_bruts_avant_local', e.target.value)} />
</div>
)}
{hasLocalTax && (
<div>
<label>Retenue à la source locale (€)</label>
<input type="number" step="0.01" value={rembForm.taxe_locale ?? 0}
onChange={e => setRembField('taxe_locale', e.target.value)} />
</div>
)}
<div>
<label>Capital (€)</label>
<input type="number" step="0.01" value={rembForm.capital ?? 0}
onChange={e => setRembField('capital', e.target.value)} />
</div>
<div>
<label>
{hasLocalTax ? 'Intérêts bruts après retenue locale (€)' : 'Intérêts bruts (€)'}
</label>
<input type="number" step="0.01" value={rembForm.interets_bruts ?? 0}
readOnly={!!hasLocalTax}
style={hasLocalTax ? { background: 'var(--surface-2)', color: 'var(--text-muted)', cursor: 'not-allowed' } : undefined}
onChange={e => !hasLocalTax && setRembField('interets_bruts', e.target.value)} />
</div>
<div>
<label>Cashback (€)</label>
<input type="number" step="0.01" value={rembForm.cashback ?? 0}
onChange={e => setRembField('cashback', e.target.value)} />
</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>
<div>
<label>{labelPS}</label>
<input type="number" step="0.01" value={rembForm.prelev_sociaux ?? 0}
readOnly={isIndicatif}
style={isIndicatif ? { background: 'var(--surface-2)', color: 'var(--text-muted)', cursor: 'not-allowed' } : undefined}
onChange={e => !isIndicatif && setRembField('prelev_sociaux', e.target.value)} />
</div>
<div>
<label>{labelIR}</label>
<input type="number" step="0.01" value={rembForm.prelev_forfaitaire ?? 0}
readOnly={isIndicatif}
style={isIndicatif ? { background: 'var(--surface-2)', color: 'var(--text-muted)', cursor: 'not-allowed' } : undefined}
onChange={e => !isIndicatif && setRembField('prelev_forfaitaire', e.target.value)} />
</div>
<div>
<label>Total prélèvements (€)</label>
<input type="number" step="0.01"
value={round2((Number(rembForm.prelev_sociaux) || 0) + (Number(rembForm.prelev_forfaitaire) || 0))}
readOnly
style={{ background: 'var(--surface-2)', color: 'var(--text-muted)', cursor: 'not-allowed' }} />
</div>
<div style={{ gridColumn: 'span 3', display: 'flex', alignItems: 'center', gap: 8, fontSize: 13 }}>
<span style={{ color: 'var(--text-muted)' }}>Montant des intérêts après imposition</span>
<span style={{ fontWeight: 600, color: 'var(--text)' }}>{fmtEUR(interetsNets)}</span>
</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)' }}>Versement</span>
</div>
<div>
<label>Date *</label>
<input type="date" required value={rembForm.date_remb || ''}
onChange={e => setRembField('date_remb', e.target.value)} />
</div>
<div>
<label>Méthode de remboursement</label>
<select value={rembForm.methode_remboursement || 'portefeuille'}
onChange={e => {
const m = e.target.value;
const def = m === 'compte_courant' ? (comptesRembInvestisseur.find(c => c.type === 'compte_courant') ?? comptesRembInvestisseur[0]) : null;
setRembForm(f => ({ ...f, methode_remboursement: m, compte_id: def ? String(def.id) : '' }));
}}>
<option value="portefeuille">Porte-monnaie de la plateforme</option>
<option value="compte_courant">Compte courant de l'investisseur</option>
</select>
</div>
{rembForm.methode_remboursement === 'compte_courant' && (
<div>
<label>Compte de réception</label>
{comptesRembInvestisseur.length > 0 ? (
<select value={rembForm.compte_id || ''}
onChange={e => setRembForm(f => ({ ...f, compte_id: e.target.value }))}>
<option value="">— Choisir un compte —</option>
{comptesRembInvestisseur.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>
<label>Montant Versé (€)</label>
<input type="number" step="0.01" value={montantRembourse} readOnly
style={{ background: 'var(--surface-2)', color: 'var(--text-muted)', cursor: 'not-allowed' }} />
</div>
<div style={{ gridColumn: 'span 3', display: 'flex', alignItems: 'center', gap: 8, fontSize: 13 }}>
<span style={{ color: 'var(--text-muted)' }}>
{isIndicatif
? "L'imposition n'est pas déduite du montant versé (fiscalité appliquée à titre indicatif)."
: "L'imposition est déduite du montant versé (capital + cashback + intérêts nets)."}
</span>
</div>
</div>
</form>
</Modal>
);
})()}
{/* ── Modal réinvestissement ────────────────────────────────── */} {/* ── Modal réinvestissement ────────────────────────────────── */}
<Modal <Modal
+233 -1
View File
@@ -8,6 +8,9 @@ import EmptyState from '../components/EmptyState.jsx';
import InvChart from '../components/InvChart.jsx'; import InvChart from '../components/InvChart.jsx';
import InvMensuelTable from '../components/InvMensuelTable.jsx'; import InvMensuelTable from '../components/InvMensuelTable.jsx';
import SuiviObjectifs from '../components/SuiviObjectifs.jsx'; import SuiviObjectifs from '../components/SuiviObjectifs.jsx';
import DrillCellPanel from '../components/DrillCellPanel.jsx';
import RembFormModal from '../components/RembFormModal.jsx';
import { InteretsChartProvider } from '../context/InteretsChartContext.jsx';
import { fmtEUR, fmtDate, fmtStatut, memberLabel, today } from '../utils/format.js'; import { fmtEUR, fmtDate, fmtStatut, memberLabel, today } from '../utils/format.js';
import { xirr } from '../utils/xirr.js'; import { xirr } from '../utils/xirr.js';
import { usePagination } from '../hooks/usePagination.js'; import { usePagination } from '../hooks/usePagination.js';
@@ -362,11 +365,165 @@ export default function Plateformes() {
setAllCorrections(corrections); setAllCorrections(corrections);
setAllSimulRembs(Array.isArray(simulRembs) ? simulRembs : []); setAllSimulRembs(Array.isArray(simulRembs) ? simulRembs : []);
setPfuRates(Array.isArray(pfu) ? pfu : []); setPfuRates(Array.isArray(pfu) ? pfu : []);
} finally { setLoading(false); } } finally { setLoading(false); setDrillRefreshKey(k => k + 1); }
}; };
useEffect(() => { load(); /* eslint-disable-next-line */ }, [activeId, activeView]); useEffect(() => { load(); /* eslint-disable-next-line */ }, [activeId, activeView]);
/* ── Section "Mois en cours" (DrillCellPanel) — saisie/édition de remboursements ── */
const [drillRefreshKey, setDrillRefreshKey] = useState(0);
const [rembModalOpen, setRembModalOpen] = useState(false);
const [rembEditingId, setRembEditingId] = useState(null);
const [rembInv, setRembInv] = useState(null); // investissement lié au remb en cours d'édition
const [rembForm, setRembForm] = useState({});
const [rembErr, setRembErr] = useState(null);
const [rembConfirmingDelete, setRembConfirmingDelete] = useState(false);
const [comptesRembInvestisseur, setComptesRembInvestisseur] = useState([]);
const round2 = (n) => Math.round(n * 100) / 100;
const getRatesForYear = (dateStr) => {
if (!dateStr || !pfuRates.length) return null;
const year = parseInt(dateStr.slice(0, 4), 10);
return pfuRates.find(r => r.annee === year)
?? pfuRates.reduce((best, r) => r.annee > best.annee ? r : best, pfuRates[0]);
};
const loadComptesRemb = (investisseurId) => {
if (!investisseurId) { setComptesRembInvestisseur([]); return; }
api.get(`/investissements/comptes-par-investisseur/${investisseurId}`)
.then(setComptesRembInvestisseur)
.catch(() => setComptesRembInvestisseur([]));
};
const setRembField = (k, v) => {
setRembForm(prev => {
const next = { ...prev, [k]: v };
const plat = plats.find(p => p.id === rembInv?.plateforme_id);
const hasLocalTax = plat?.fiscalite === 'avec_fiscalite_locale' && plat?.taux_fiscalite_locale;
if (k === 'interets_bruts_avant_local') {
const taux = hasLocalTax ? (plat.taux_fiscalite_locale || 0) : 0;
next.taxe_locale = round2(Number(v) * taux / 100);
next.interets_bruts = round2(Number(v) - next.taxe_locale);
}
if (k === 'taxe_locale') {
next.interets_bruts = round2(Number(next.interets_bruts_avant_local) - Number(v));
}
if (k === 'date_remb' || k === 'interets_bruts' || k === 'interets_bruts_avant_local' || k === 'taxe_locale') {
const dateStr = k === 'date_remb' ? v : next.date_remb;
const bruts = Number(next.interets_bruts) || 0;
const rates = getRatesForYear(dateStr);
if (rates) {
next.prelev_sociaux = round2(bruts * rates.prelev_sociaux / 100);
next.prelev_forfaitaire = round2(bruts * rates.impot_revenu / 100);
}
}
return next;
});
};
const openEditRecu = (r) => {
const inv = allRows.find(i => i.id === r.investissement_id);
setRembInv(inv || null);
setRembEditingId(r.id);
setRembForm({
date_remb: r.date_remb,
capital: r.capital ?? 0,
cashback: r.cashback ?? 0,
interets_bruts_avant_local: r.interets_bruts_avant_local ?? 0,
taxe_locale: r.taxe_locale ?? 0,
interets_bruts: r.interets_bruts ?? 0,
prelev_sociaux: r.prelev_sociaux ?? 0,
prelev_forfaitaire: r.prelev_forfaitaire ?? 0,
statut: r.statut,
notes: r.notes || '',
methode_remboursement: r.methode_remboursement || 'portefeuille',
compte_id: r.compte_id || '',
});
setRembErr(null);
setRembConfirmingDelete(false);
setRembModalOpen(true);
loadComptesRemb(inv?.investisseur_id);
};
const openFromProjet = (p) => {
const inv = allRows.find(i => i.id === p.investissement_id);
const plat = inv ? plats.find(pl => pl.id === inv.plateforme_id) : null;
const bruts = p.interets_prevus || 0;
const rates = getRatesForYear(p.date_prevue);
const methode = plat && plat.methode_remboursement !== 'choix_investisseur'
? plat.methode_remboursement
: (inv?.methode_remboursement || 'portefeuille');
const hasLocalTax = plat?.fiscalite === 'avec_fiscalite_locale' && plat?.taux_fiscalite_locale;
const taxe_locale = hasLocalTax ? round2(bruts * plat.taux_fiscalite_locale / 100) : 0;
const brutsApresLocal = hasLocalTax ? round2(bruts - taxe_locale) : bruts;
setRembInv(inv || null);
setRembEditingId(null);
setRembForm({
date_remb: p.date_prevue,
capital: p.capital_prevu || 0,
cashback: 0,
interets_bruts_avant_local: hasLocalTax ? bruts : 0,
taxe_locale,
interets_bruts: brutsApresLocal,
prelev_sociaux: rates ? round2(brutsApresLocal * rates.prelev_sociaux / 100) : 0,
prelev_forfaitaire: rates ? round2(brutsApresLocal * rates.impot_revenu / 100) : 0,
statut: 'paye',
notes: '',
methode_remboursement: methode,
compte_id: methode === 'compte_courant' ? (inv?.compte_id || '') : '',
});
setRembErr(null);
setRembConfirmingDelete(false);
setRembModalOpen(true);
loadComptesRemb(inv?.investisseur_id);
};
const closeRembModal = () => {
setRembModalOpen(false);
setRembEditingId(null);
setRembInv(null);
setRembForm({});
setRembErr(null);
setRembConfirmingDelete(false);
};
const submitRemb = async (e) => {
e?.preventDefault?.();
setRembErr(null);
try {
const payload = {
type: 'normal',
investissement_id: rembInv?.id,
date_remb: rembForm.date_remb,
capital: Number(rembForm.capital || 0),
cashback: Number(rembForm.cashback || 0),
interets_bruts_avant_local: Number(rembForm.interets_bruts_avant_local || 0),
taxe_locale: Number(rembForm.taxe_locale || 0),
interets_bruts: Number(rembForm.interets_bruts || 0),
prelev_sociaux: Number(rembForm.prelev_sociaux || 0),
prelev_forfaitaire: Number(rembForm.prelev_forfaitaire || 0),
statut: rembForm.statut,
notes: rembForm.notes || undefined,
methode_remboursement: rembForm.methode_remboursement || 'portefeuille',
compte_id: rembForm.methode_remboursement === 'compte_courant' && rembForm.compte_id ? Number(rembForm.compte_id) : null,
};
if (rembEditingId) await api.put(`/remboursements/${rembEditingId}`, payload);
else await api.post('/remboursements', payload);
closeRembModal();
await load();
} catch (e) { setRembErr(e.message); }
};
const onDeleteRemb = async () => {
try {
await api.del(`/remboursements/${rembEditingId}`);
closeRembModal();
await load();
} catch (e) { setRembErr(e.message); }
};
/* ── Icônes bibliothèque ── */ /* ── Icônes bibliothèque ── */
useEffect(() => { useEffect(() => {
api.get('/icons').then(rows => { api.get('/icons').then(rows => {
@@ -449,6 +606,22 @@ export default function Plateformes() {
return new Set(matching.filter(p => String(p.investisseur_id) === String(selectedDetenteurId)).map(p => p.id)); return new Set(matching.filter(p => String(p.investisseur_id) === String(selectedDetenteurId)).map(p => p.id));
}, [plats, selectedPlatName, selectedDetenteurId]); }, [plats, selectedPlatName, selectedDetenteurId]);
/* ── Cellule "mois en cours" pour la section DrillCellPanel ── */
const MOIS_LONG_CELL = ['Janvier','Février','Mars','Avril','Mai','Juin','Juillet','Août','Septembre','Octobre','Novembre','Décembre'];
const moisEnCoursCell = useMemo(() => {
const ids = [...filteredPlatIds];
if (!selectedPlatName || ids.length === 0) return null;
const now = new Date();
return {
platId: ids.length === 1 ? ids[0] : null,
platIds: ids,
platNom: selectedPlatName,
annee: now.getFullYear(),
mois: now.getMonth() + 1,
moisLabel: MOIS_LONG_CELL[now.getMonth()],
};
}, [filteredPlatIds, selectedPlatName]);
/* ── Lignes filtrées (plateforme + détenteur) ── */ /* ── Lignes filtrées (plateforme + détenteur) ── */
const filteredRows = useMemo(() => { const filteredRows = useMemo(() => {
if (selectedDetenteurId === 'all' || !detenteurOptions) return platRows; if (selectedDetenteurId === 'all' || !detenteurOptions) return platRows;
@@ -831,6 +1004,8 @@ export default function Plateformes() {
<div className="dr-tabs"> <div className="dr-tabs">
<button className={`dr-tab${activeTab === 'remboursements' ? ' active' : ''}`} <button className={`dr-tab${activeTab === 'remboursements' ? ' active' : ''}`}
onClick={() => setActiveTab('remboursements')}>Remboursements</button> onClick={() => setActiveTab('remboursements')}>Remboursements</button>
<button className={`dr-tab${activeTab === 'mois-en-cours' ? ' active' : ''}`}
onClick={() => setActiveTab('mois-en-cours')}>Mois en cours</button>
<button className={`dr-tab${activeTab === 'depots-retraits' ? ' active' : ''}`} <button className={`dr-tab${activeTab === 'depots-retraits' ? ' active' : ''}`}
onClick={() => setActiveTab('depots-retraits')}>Dépôts / Retraits</button> onClick={() => setActiveTab('depots-retraits')}>Dépôts / Retraits</button>
<button className={`dr-tab${activeTab === 'capital-investi' ? ' active' : ''}`} <button className={`dr-tab${activeTab === 'capital-investi' ? ' active' : ''}`}
@@ -1253,6 +1428,43 @@ export default function Plateformes() {
); );
})()} })()}
{/* ====== ONGLET MOIS EN COURS ====== */}
{activeTab === 'mois-en-cours' && (
<div style={{ padding: '0 24px' }}>
{moisEnCoursCell ? (
<div className="solde-chart-wrap" style={{ padding: '20px 24px 24px' }}>
<div style={{ marginBottom: 12, display: 'flex', alignItems: 'baseline', gap: 8 }}>
<span style={{ fontSize: 11, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '.06em', color: 'var(--text-muted)' }}>
Mois en cours
</span>
<span style={{ fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>
{moisEnCoursCell.moisLabel} {moisEnCoursCell.annee}
</span>
</div>
<InteretsChartProvider netMode={netMode} pfuRates={pfuRates} activeView={activeView} activeId={activeId}>
<DrillCellPanel
cell={moisEnCoursCell}
alwaysOpen
pfuRates={pfuRates}
activeView={activeView}
activeId={activeId}
onEditRecu={openEditRecu}
onEditProjet={openFromProjet}
refreshKey={drillRefreshKey}
investissements={platRows}
plateformes={plats.filter(p => filteredPlatIds.has(p.id))}
onBulkDone={load}
/>
</InteretsChartProvider>
</div>
) : (
<div style={{ padding: '24px 0', textAlign: 'center', color: 'var(--text-muted)', fontSize: 'var(--fs-sm)' }}>
Aucune plateforme sélectionnée.
</div>
)}
</div>
)}
{/* ====== ONGLET DÉPÔTS / RETRAITS ====== */} {/* ====== ONGLET DÉPÔTS / RETRAITS ====== */}
{activeTab === 'depots-retraits' && (() => { {activeTab === 'depots-retraits' && (() => {
const currentYear = new Date().getFullYear(); const currentYear = new Date().getFullYear();
@@ -1678,6 +1890,26 @@ export default function Plateformes() {
</div> </div>
</div> </div>
)} )}
{/* ── Modal saisie/édition remboursement (section "Mois en cours") ── */}
<RembFormModal
open={rembModalOpen}
editingId={rembEditingId}
onClose={closeRembModal}
onSubmit={submitRemb}
onDelete={onDeleteRemb}
confirmingDelete={rembConfirmingDelete}
setConfirmingDelete={setRembConfirmingDelete}
err={rembErr}
form={rembForm}
onFieldChange={setRembField}
onFormPatch={patch => setRembForm(f => ({ ...f, ...patch }))}
pfuRates={pfuRates}
comptesInvestisseur={comptesRembInvestisseur}
investissements={rembInv ? [rembInv] : []}
plateformes={plats}
locked
/>
</> </>
); );
} }
+53 -210
View File
@@ -7,12 +7,12 @@ import { 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 { useUi } from '../context/UiContext.jsx'; import { useUi } from '../context/UiContext.jsx';
import Modal from '../components/Modal.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 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 { InteretsChartProvider } from '../context/InteretsChartContext.jsx'; import { InteretsChartProvider } from '../context/InteretsChartContext.jsx';
import { fmtEUR, fmtDate, today, memberLabel } from '../utils/format.js'; import { fmtEUR, fmtDate, today, memberLabel } from '../utils/format.js';
import * as XLSX from 'xlsx'; import * as XLSX from 'xlsx';
@@ -195,6 +195,18 @@ export default function Remboursements() {
const drillPanelRef = useRef(null); const drillPanelRef = useRef(null);
const tipTableRef = useRef(null); const tipTableRef = useRef(null);
/* ── Cellule "mois en cours" (onglet dédié, toutes plateformes) ── */
const moisEnCoursCell = useMemo(() => {
const now = new Date();
return {
platId: null,
platNom: null,
annee: now.getFullYear(),
mois: now.getMonth() + 1,
moisLabel: MOIS_FR[now.getMonth()],
};
}, []);
/* ── Navigation retour Dashboard ── */ /* ── Navigation retour Dashboard ── */
const autoOpenRef = useRef(false); // garantit l'ouverture auto une seule fois const autoOpenRef = useRef(false); // garantit l'ouverture auto une seule fois
const backToDashboardRef = useRef(null); // URL de retour quand from=dashboard const backToDashboardRef = useRef(null); // URL de retour quand from=dashboard
@@ -837,6 +849,8 @@ export default function Remboursements() {
onClick={() => setActiveTab('plateformes')}>Plateformes</button> onClick={() => setActiveTab('plateformes')}>Plateformes</button>
<button className={`dr-tab${activeTab === 'vision-mensuelle' ? ' active' : ''}`} <button className={`dr-tab${activeTab === 'vision-mensuelle' ? ' active' : ''}`}
onClick={() => setActiveTab('vision-mensuelle')}>Vision mensuelle</button> onClick={() => setActiveTab('vision-mensuelle')}>Vision mensuelle</button>
<button className={`dr-tab${activeTab === 'mois-en-cours' ? ' active' : ''}`}
onClick={() => setActiveTab('mois-en-cours')}>Mois en cours</button>
<button className={`dr-tab${activeTab === 'remboursements' ? ' active' : ''}`} <button className={`dr-tab${activeTab === 'remboursements' ? ' active' : ''}`}
onClick={() => setActiveTab('remboursements')}>Remboursements</button> onClick={() => setActiveTab('remboursements')}>Remboursements</button>
<button className={`dr-tab${activeTab === 'projections' ? ' active' : ''}`} <button className={`dr-tab${activeTab === 'projections' ? ' active' : ''}`}
@@ -1025,6 +1039,27 @@ export default function Remboursements() {
</div> </div>
)} )}
{/* ══ ONGLET MOIS EN COURS ══ */}
{activeTab === 'mois-en-cours' && (
<div style={{ padding: '0 24px' }}>
<InteretsChartProvider netMode={netMode} pfuRates={pfuRates} activeView={activeView} activeId={activeId}>
<DrillCellPanel
cell={moisEnCoursCell}
alwaysOpen
pfuRates={pfuRates}
activeView={activeView}
activeId={activeId}
onEditRecu={openEdit}
onEditProjet={openFromSimul}
refreshKey={drillRefreshKey}
investissements={investissements}
plateformes={plateformes}
onBulkDone={load}
/>
</InteretsChartProvider>
</div>
)}
{/* ══ ONGLET REMBOURSEMENTS ══ */} {/* ══ ONGLET REMBOURSEMENTS ══ */}
{activeTab === 'remboursements' && ( {activeTab === 'remboursements' && (
<div style={{ padding: '0 24px' }}> <div style={{ padding: '0 24px' }}>
@@ -1409,12 +1444,6 @@ export default function Remboursements() {
{/* ══ MODAL ══ */} {/* ══ MODAL ══ */}
{(() => { {(() => {
const pfuYear = getRatesForYear(form.date_remb);
const fmtRate = (n) => n != null ? n.toFixed(1).replace('.', ',') + ' %' : '…';
const labelPS = `Prélèvements sociaux${pfuYear ? ` (${fmtRate(pfuYear.prelev_sociaux)})` : ''} (€)`;
const labelIR = `Impôt sur le revenu${pfuYear ? ` (${fmtRate(pfuYear.impot_revenu)})` : ''} (€)`;
const interetsNets = computeInteretsNets(form);
// Liste d'investissements disponibles dans le select // Liste d'investissements disponibles dans le select
const invListForSelect = (editingId const invListForSelect = (editingId
? investissements.filter(i => i.statut !== 'rembourse' || i.id === Number(form.investissement_id)) ? investissements.filter(i => i.statut !== 'rembourse' || i.id === Number(form.investissement_id))
@@ -1426,212 +1455,26 @@ export default function Remboursements() {
return (a.nom_projet || '').localeCompare(b.nom_projet || '', 'fr'); return (a.nom_projet || '').localeCompare(b.nom_projet || '', 'fr');
}); });
// Méthode de remboursement — plateforme de l'investissement sélectionné
const currentInv = !isBonus ? investissements.find(i => i.id === Number(form.investissement_id)) : null;
const currentPlat = currentInv ? plateformes.find(p => p.id === currentInv.plateforme_id) : null;
const isChoixOuvert = currentPlat?.methode_remboursement === 'choix_investisseur';
const hasLocalTax = !isBonus && currentPlat?.fiscalite === 'avec_fiscalite_locale' && currentPlat?.taux_fiscalite_locale;
const isExonere = !isBonus && currentInv?.fiscalite_override === 'exonere';
const isIndicatif = isExonere || (!isBonus && !!currentPlat && currentPlat?.fiscalite !== 'flat_tax');
const netRecu = isIndicatif
? round2(Number(form.capital||0) + Number(form.cashback||0) + Number(form.interets_bruts||0))
: computeNet(form);
return ( return (
<Modal <RembFormModal
open={modalOpen} open={modalOpen}
title={editingId ? 'Modifier le remboursement' : 'Nouveau remboursement'} editingId={editingId}
onClose={close} onClose={close}
width={900} onSubmit={submit}
footer={ onDelete={onDelete}
<div style={{ display: 'flex', alignItems: 'center', width: '100%', gap: 8 }}> confirmingDelete={confirmingDelete}
<div style={{ flex: 1 }}> setConfirmingDelete={setConfirmingDelete}
{editingId && ( err={err}
!confirmingDelete ? ( form={form}
<button className="danger" onClick={() => setConfirmingDelete(true)}> onFieldChange={setField}
Supprimer onFormPatch={patch => setForm(f => ({ ...f, ...patch }))}
</button> pfuRates={pfuRates}
) : ( comptesInvestisseur={comptesInvestisseur}
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}> investissements={investissements}
<span style={{ fontSize: 13, color: 'var(--danger, #ef4444)', whiteSpace: 'nowrap' }}> plateformes={plateformes}
Supprimer définitivement ? investissementOptions={invListForSelect}
</span> multiDetenteur={multiDetenteur}
<button onClick={() => setConfirmingDelete(false)}>Non</button> />
<button className="danger" onClick={onDelete}>Oui, supprimer</button>
</span>
)
)}
</div>
<button onClick={close}>Annuler</button>
<button className="primary" onClick={submit}>{editingId ? 'Enregistrer' : 'Créer'}</button>
</div>
}
>
<form onSubmit={submit}>
{err && <div className="error">{err}</div>}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '12px 16px' }}>
{/* ── Sélecteur d'investissement ── */}
<div style={{ gridColumn: isBonus ? 'span 1' : 'span 3' }}>
<label>Investissement *</label>
<select required value={form.investissement_id}
onChange={e => setField('investissement_id', e.target.value)}>
<option value="">—</option>
<option value="BONUS_PARRAINAGE">🎁 Bonus Parrainage</option>
<option value="BONUS_PLATEFORME">🏆 Bonus Plateforme</option>
<optgroup label="──────────────────" />
{invListForSelect.map(i =>
<option key={i.id} value={i.id}>{i.plateforme_nom} — {i.nom_projet} ({fmtEUR(i.montant_investi)})</option>
)}
</select>
</div>
{isBonus && (
<div style={{ gridColumn: 'span 2' }}>
<label>Plateforme associée (bonus)</label>
<select value={form.bonus_plateforme_id} onChange={e => {
const plat = plateformes.find(p => String(p.id) === e.target.value);
setForm({ ...form, bonus_plateforme_id: e.target.value, bonus_investisseur_id: plat?.investisseur_id ? String(plat.investisseur_id) : '' });
}}>
<option value="">— Aucune —</option>
{plateformes.map(p => <option key={p.id} value={p.id}>{p.nom}{multiDetenteur && p.investisseur_nom ? ` — ${p.investisseur_nom}` : ''}</option>)}
</select>
</div>
)}
{/* ── Champs remboursement normal ── */}
{!isBonus && (<>
<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)' }}>Remboursement</span>
</div>
{hasLocalTax && (
<div style={{ gridColumn: 'span 2' }}>
<label>Intérêts bruts avant retenue locale ({currentPlat.taux_fiscalite_locale} %) (€)</label>
<input type="number" step="0.01" value={form.interets_bruts_avant_local}
onChange={e => setField('interets_bruts_avant_local', e.target.value)} />
</div>
)}
{hasLocalTax && (
<div>
<label>Retenue à la source locale (€)</label>
<input type="number" step="0.01" value={form.taxe_locale}
onChange={e => setField('taxe_locale', e.target.value)} />
</div>
)}
<div>
<label>Capital (€)</label>
<input type="number" step="0.01" value={form.capital}
onChange={e => setField('capital', e.target.value)} />
</div>
<div>
<label>{hasLocalTax ? 'Intérêts bruts après retenue locale (€)' : 'Intérêts bruts (€)'}</label>
<input type="number" step="0.01" value={form.interets_bruts}
readOnly={!!hasLocalTax}
style={hasLocalTax ? { background: 'var(--surface-2)', color: 'var(--text-muted)', cursor: 'not-allowed' } : undefined}
onChange={e => !hasLocalTax && setField('interets_bruts', e.target.value)} />
</div>
<div>
<label>Cashback (€)</label>
<input type="number" step="0.01" value={form.cashback}
onChange={e => setField('cashback', e.target.value)} />
</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>
<div>
<label>{labelPS}</label>
<input type="number" step="0.01" value={form.prelev_sociaux}
readOnly={isIndicatif}
style={isIndicatif ? { background: 'var(--surface-2)', color: 'var(--text-muted)', cursor: 'not-allowed' } : undefined}
onChange={e => !isIndicatif && setForm({ ...form, prelev_sociaux: Number(e.target.value) })} />
</div>
<div>
<label>{labelIR}</label>
<input type="number" step="0.01" value={form.prelev_forfaitaire}
readOnly={isIndicatif}
style={isIndicatif ? { background: 'var(--surface-2)', color: 'var(--text-muted)', cursor: 'not-allowed' } : undefined}
onChange={e => !isIndicatif && setForm({ ...form, prelev_forfaitaire: Number(e.target.value) })} />
</div>
<div>
<label>Total prélèvements (€)</label>
<input type="number" step="0.01"
value={round2((Number(form.prelev_sociaux) || 0) + (Number(form.prelev_forfaitaire) || 0))}
readOnly
style={{ background: 'var(--surface-2)', color: 'var(--text-muted)', cursor: 'not-allowed' }} />
</div>
<div style={{ gridColumn: 'span 3', display: 'flex', alignItems: 'center', gap: 8, fontSize: 13 }}>
<span style={{ color: 'var(--text-muted)' }}>Montant des intérêts après imposition</span>
<span style={{ fontWeight: 600, color: 'var(--text)' }}>{fmtEUR(interetsNets)}</span>
</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)' }}>Versement</span>
</div>
<div>
<label>Date *</label>
<input type="date" required value={form.date_remb}
onChange={e => setField('date_remb', e.target.value)} />
</div>
<div>
<label>Méthode de remboursement</label>
<select value={form.methode_remboursement || 'portefeuille'}
onChange={e => {
const m = e.target.value;
const def = m === 'compte_courant' ? (comptesInvestisseur.find(c => c.type === 'compte_courant') ?? comptesInvestisseur[0]) : null;
setForm(f => ({ ...f, methode_remboursement: m, compte_id: def ? String(def.id) : '' }));
}}>
<option value="portefeuille">Porte-monnaie de la plateforme</option>
<option value="compte_courant">Compte courant de l'investisseur</option>
</select>
</div>
<div>
{form.methode_remboursement === 'compte_courant' && (
<>
<label>Compte de réception</label>
{comptesInvestisseur.length > 0 ? (
<select value={form.compte_id || ''}
onChange={e => setForm(f => ({ ...f, 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>
)}
</>
)}
<label>Montant versé (€)</label>
<input type="number" step="0.01" value={netRecu} readOnly
style={{ background: 'var(--surface-2)', color: 'var(--text-muted)', cursor: 'not-allowed' }} />
</div>
<div style={{ gridColumn: 'span 3', display: 'flex', alignItems: 'center', gap: 8, fontSize: 13 }}>
<span style={{ color: 'var(--text-muted)' }}>
{isIndicatif
? "L'imposition n'est pas déduite du montant versé (fiscalité appliquée à titre indicatif)."
: "L'imposition est déduite du montant versé (capital + cashback + intérêts nets)."}
</span>
</div>
</>)}
{/* ── Bonus ── */}
{isBonus && (
<div>
<label>Date *</label>
<input type="date" required value={form.date_remb}
onChange={e => setField('date_remb', e.target.value)} />
</div>
)}
{isBonus && (
<div style={{ gridColumn: 'span 2' }}>
<label>Montant (€)</label>
<input type="number" step="0.01" value={form.cashback}
onChange={e => setField('cashback', e.target.value)} />
</div>
)}
</div>
</form>
</Modal>
); );
})()} })()}
{openMenu && ( {openMenu && (