diff --git a/frontend/src/components/DrillCellPanel.jsx b/frontend/src/components/DrillCellPanel.jsx index e04435e..fa0f5b9 100644 --- a/frontend/src/components/DrillCellPanel.jsx +++ b/frontend/src/components/DrillCellPanel.jsx @@ -14,6 +14,19 @@ function hexToRgba(hex, a) { 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 ( + + ); +} + /** * DrillCellPanel * @@ -45,6 +58,7 @@ export default function DrillCellPanel({ } = useInteretsChart(); const [data, setData] = useState(null); + const [prevData, setPrevData] = useState(null); const [loading, setLoading] = useState(false); /* filterPlatId : '' = toutes, sinon l'id (string) de la plateforme sélectionnée dans le dropdown */ const [filterPlatId, setFilterPlatId] = useState(''); @@ -57,6 +71,7 @@ export default function DrillCellPanel({ const [bulkProcessing, setBulkProcessing] = useState(false); const [bulkProgress, setBulkProgress] = useState(0); const [bulkDone, setBulkDone] = useState(false); + const [bulkErrors, setBulkErrors] = useState([]); /* ── Reset filtres quand la cellule change ── */ useEffect(() => { @@ -94,6 +109,32 @@ export default function DrillCellPanel({ .finally(() => setLoading(false)); }, [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 ── */ const pfuReduction = useMemo(() => { 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 || '')); }, [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; /* ── Afficher la colonne Plateforme quand on est en mode "toutes" ── */ @@ -136,6 +193,36 @@ export default function DrillCellPanel({ 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 ── */ const totalRecus = recus.reduce((s, r) => s + recuRowValue(r), 0); const totalProjetes = projetes.reduce((s, p) => s + projRowValue(p), 0); @@ -195,7 +282,7 @@ export default function DrillCellPanel({ statut: 'paye', notes: '', 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); @@ -208,12 +295,18 @@ export default function DrillCellPanel({ const runBulk = async () => { setBulkProcessing(true); let done = 0; + const errors = []; for (const item of bulkItems) { 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++; setBulkProgress(done); } + setBulkErrors(errors); setBulkProcessing(false); setBulkDone(true); if (onBulkDone) onBulkDone(); @@ -225,6 +318,7 @@ export default function DrillCellPanel({ setBulkProgress(0); setBulkDone(false); setBulkProcessing(false); + setBulkErrors([]); }; /* ── Rendu ── */ @@ -356,7 +450,12 @@ export default function DrillCellPanel({ > {fmtDate(r.date_remb)} {showPlatCol && {r.plateforme_nom || '—'}} - {r.nom_projet} + + + {r.nom_projet} + + + {r.detenteur_nom || '—'} {cols.map(c => { let v; @@ -434,7 +533,12 @@ export default function DrillCellPanel({ > {fmtDate(p.date_prevue)} {showPlatCol && {p.plateforme_nom || '—'}} - {p.nom_projet} + + + {p.nom_projet} + + + {p.detenteur_nom || '—'} {cols.map(c => { let v; @@ -529,21 +633,60 @@ export default function DrillCellPanel({ )} - {bulkDone && ( -
-
- - - + {bulkDone && (() => { + const successCount = bulkItems.length - bulkErrors.length; + const allOk = bulkErrors.length === 0; + return ( +
+
+ {allOk ? ( + + + + ) : ( + + + + + )} +
+

+ {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' : ''}`} +

+

+ {cell.moisLabel} {cell.annee} +

+ {!allOk && ( +
+ + + + + + + + + + {bulkErrors.map((e, i) => ( + + + + + + ))} + +
ProjetDateErreur
{e.plat ? `${e.plat} — ` : ''}{e.label}{fmtDate(e.date)}{e.message}
+
+ )}
-

- {bulkItems.length} remboursement{bulkItems.length > 1 ? 's' : ''} enregistré{bulkItems.length > 1 ? 's' : ''} -

-

- {cell.moisLabel} {cell.annee} -

-
- )} + ); + })()} {!bulkDone && ( <>

diff --git a/frontend/src/components/RembFormModal.jsx b/frontend/src/components/RembFormModal.jsx new file mode 100644 index 0000000..910d7d8 --- /dev/null +++ b/frontend/src/components/RembFormModal.jsx @@ -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 onFieldChange('investissement_id', e.target.value)}> + + + + + {investissementOptions.map(i => + + )} + +

+ )} + {!locked && isBonus && ( +
+ + +
+ )} + + {/* ── Champs remboursement normal ── */} + {!isBonus && (<> +
+ Remboursement +
+ {hasLocalTax && ( +
+ + onFieldChange('interets_bruts_avant_local', e.target.value)} /> +
+ )} + {hasLocalTax && ( +
+ + onFieldChange('taxe_locale', e.target.value)} /> +
+ )} +
+ + onFieldChange('capital', e.target.value)} /> +
+
+ + !hasLocalTax && onFieldChange('interets_bruts', e.target.value)} /> +
+
+ + onFieldChange('cashback', e.target.value)} /> +
+
+ Imposition{isIndicatif ? ' — indicatif' : ''} +
+
+ + !isIndicatif && onFormPatch({ prelev_sociaux: Number(e.target.value) })} /> +
+
+ + !isIndicatif && onFormPatch({ prelev_forfaitaire: Number(e.target.value) })} /> +
+
+ + +
+
+ Montant des intérêts après imposition + {fmtEUR(interetsNets)} +
+
+ Versement +
+
+ + onFieldChange('date_remb', e.target.value)} /> +
+
+ + +
+
+ {form.methode_remboursement === 'compte_courant' && ( + <> + + {comptesInvestisseur?.length > 0 ? ( + + ) : ( +

+ Aucun compte défini. Créer un compte → +

+ )} + + )} + + +
+
+ + {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)."} + +
+ )} + + {/* ── Bonus (mode non verrouillé uniquement) ── */} + {!locked && isBonus && ( +
+ + onFieldChange('date_remb', e.target.value)} /> +
+ )} + {!locked && isBonus && ( +
+ + onFieldChange('cashback', e.target.value)} /> +
+ )} + + + + + ); +} diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx index 86cc9df..7ef311b 100644 --- a/frontend/src/pages/Dashboard.jsx +++ b/frontend/src/pages/Dashboard.jsx @@ -424,6 +424,8 @@ export default function Dashboard() { const [capitalMensuelData, setCapitalMensuelData] = useState([]); const [plateformes, setPlateformes] = useState([]); 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 ── */ const _now = new Date(); @@ -466,14 +468,30 @@ export default function Dashboard() { 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(() => { - if (!activeId && activeView !== 'all') return; setData(null); setLoading(true); - const params = activeView === 'all' ? { scope: 'all' } : undefined; - api.get('/dashboard', params).then(setData).finally(() => setLoading(false)); + loadDashboardData().finally(() => setLoading(false)); + /* eslint-disable-next-line */ }, [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; if (!ready) return
Sélectionnez un compte investisseur.
; if (loading || !data) return
Chargement…
; @@ -505,7 +523,7 @@ export default function Dashboard() { <>

Tableau de bord — {viewTitle}

- + {/* ── KPI + sélecteur année ── */}
@@ -545,8 +563,9 @@ export default function Dashboard() { activeView={activeView} activeId={activeId} plateformes={plateformes} - investissements={[]} - onBulkDone={() => {}} + investissements={investissements} + refreshKey={drillRefreshKey} + onBulkDone={onDrillBulkDone} onEditRecu={(r) => { const q = new URLSearchParams({ 'edit-remb': r.id, diff --git a/frontend/src/pages/InvestissementDetail.jsx b/frontend/src/pages/InvestissementDetail.jsx index dec0b2b..92f1654 100644 --- a/frontend/src/pages/InvestissementDetail.jsx +++ b/frontend/src/pages/InvestissementDetail.jsx @@ -5,6 +5,7 @@ import { useInvestisseur } from '../context/InvestisseurContext.jsx'; import { useUi } from '../context/UiContext.jsx'; import Modal from '../components/Modal.jsx'; import ConfirmModal from '../components/ConfirmModal.jsx'; +import RembFormModal from '../components/RembFormModal.jsx'; import CountrySelect, { COUNTRIES, FlagIcon } from '../components/CountrySelect.jsx'; import { fmtEUR, fmtPct, fmtDate, today } from '../utils/format.js'; import { xirr } from '../utils/xirr.js'; @@ -2194,177 +2195,24 @@ export default function InvestissementDetail() { {/* ── Modal saisie remboursement (depuis projection) ────────── */} - {(() => { - 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 ( - - {editingRembId && !confirmingRembDelete && ( - - )} - {confirmingRembDelete && ( -
- - Supprimer ce remboursement ? - - - -
- )} - {!confirmingRembDelete && ( -
- - -
- )} -
- } - > -
- {rembErr &&
{rembErr}
} -
-
- Remboursement -
- {hasLocalTax && ( -
- - setRembField('interets_bruts_avant_local', e.target.value)} /> -
- )} - {hasLocalTax && ( -
- - setRembField('taxe_locale', e.target.value)} /> -
- )} -
- - setRembField('capital', e.target.value)} /> -
-
- - !hasLocalTax && setRembField('interets_bruts', e.target.value)} /> -
-
- - setRembField('cashback', e.target.value)} /> -
-
- Imposition{isIndicatif ? ' — indicatif' : ''} -
-
- - !isIndicatif && setRembField('prelev_sociaux', e.target.value)} /> -
-
- - !isIndicatif && setRembField('prelev_forfaitaire', e.target.value)} /> -
-
- - -
-
- Montant des intérêts après imposition - {fmtEUR(interetsNets)} -
-
- Versement -
-
- - setRembField('date_remb', e.target.value)} /> -
-
- - -
- {rembForm.methode_remboursement === 'compte_courant' && ( -
- - {comptesRembInvestisseur.length > 0 ? ( - - ) : ( -

- Aucun compte défini. Créer un compte → -

- )} -
- )} -
- - -
-
- - {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)."} - -
-
-
- - ); - })()} + setRembForm(f => ({ ...f, ...patch }))} + pfuRates={pfuRates} + comptesInvestisseur={comptesRembInvestisseur} + investissements={inv ? [inv] : []} + plateformes={plats} + locked + /> {/* ── Modal réinvestissement ────────────────────────────────── */} k + 1); } }; 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 ── */ useEffect(() => { 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)); }, [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) ── */ const filteredRows = useMemo(() => { if (selectedDetenteurId === 'all' || !detenteurOptions) return platRows; @@ -831,6 +1004,8 @@ export default function Plateformes() {
+
+ )} + {/* ====== ONGLET DÉPÔTS / RETRAITS ====== */} {activeTab === 'depots-retraits' && (() => { const currentYear = new Date().getFullYear(); @@ -1678,6 +1890,26 @@ export default function Plateformes() { )} + + {/* ── Modal saisie/édition remboursement (section "Mois en cours") ── */} + setRembForm(f => ({ ...f, ...patch }))} + pfuRates={pfuRates} + comptesInvestisseur={comptesRembInvestisseur} + investissements={rembInv ? [rembInv] : []} + plateformes={plats} + locked + /> ); } diff --git a/frontend/src/pages/Remboursements.jsx b/frontend/src/pages/Remboursements.jsx index f0d86e8..f7f5979 100644 --- a/frontend/src/pages/Remboursements.jsx +++ b/frontend/src/pages/Remboursements.jsx @@ -7,12 +7,12 @@ import { useLocation, useNavigate } from 'react-router-dom'; import { api } from '../api.js'; import { useInvestisseur } from '../context/InvestisseurContext.jsx'; import { useUi } from '../context/UiContext.jsx'; -import Modal from '../components/Modal.jsx'; import ConfirmModal from '../components/ConfirmModal.jsx'; import InteretsChart from '../components/InteretsChart.jsx'; import InteretsDistributionChart from '../components/InteretsDistributionChart.jsx'; import TableauInteretsPlateforme from '../components/TableauInteretsPlateforme.jsx'; import DrillCellPanel from '../components/DrillCellPanel.jsx'; +import RembFormModal from '../components/RembFormModal.jsx'; import { InteretsChartProvider } from '../context/InteretsChartContext.jsx'; import { fmtEUR, fmtDate, today, memberLabel } from '../utils/format.js'; import * as XLSX from 'xlsx'; @@ -195,6 +195,18 @@ export default function Remboursements() { const drillPanelRef = 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 ── */ const autoOpenRef = useRef(false); // garantit l'ouverture auto une seule fois const backToDashboardRef = useRef(null); // URL de retour quand from=dashboard @@ -837,6 +849,8 @@ export default function Remboursements() { onClick={() => setActiveTab('plateformes')}>Plateformes + - ) : ( - - - Supprimer définitivement ? - - - - - ) - )} - - - - - } - > -
- {err &&
{err}
} -
- {/* ── Sélecteur d'investissement ── */} -
- - -
- {isBonus && ( -
- - -
- )} - - {/* ── Champs remboursement normal ── */} - {!isBonus && (<> -
- Remboursement -
- {hasLocalTax && ( -
- - setField('interets_bruts_avant_local', e.target.value)} /> -
- )} - {hasLocalTax && ( -
- - setField('taxe_locale', e.target.value)} /> -
- )} -
- - setField('capital', e.target.value)} /> -
-
- - !hasLocalTax && setField('interets_bruts', e.target.value)} /> -
-
- - setField('cashback', e.target.value)} /> -
-
- Imposition{isIndicatif ? ' — indicatif' : ''} -
-
- - !isIndicatif && setForm({ ...form, prelev_sociaux: Number(e.target.value) })} /> -
-
- - !isIndicatif && setForm({ ...form, prelev_forfaitaire: Number(e.target.value) })} /> -
-
- - -
-
- Montant des intérêts après imposition - {fmtEUR(interetsNets)} -
-
- Versement -
-
- - setField('date_remb', e.target.value)} /> -
-
- - -
-
- {form.methode_remboursement === 'compte_courant' && ( - <> - - {comptesInvestisseur.length > 0 ? ( - - ) : ( -

- Aucun compte défini. Créer un compte → -

- )} - - )} - - -
-
- - {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)."} - -
- )} - - {/* ── Bonus ── */} - {isBonus && ( -
- - setField('date_remb', e.target.value)} /> -
- )} - {isBonus && ( -
- - setField('cashback', e.target.value)} /> -
- )} - -
-
-
+ onSubmit={submit} + onDelete={onDelete} + confirmingDelete={confirmingDelete} + setConfirmingDelete={setConfirmingDelete} + err={err} + form={form} + onFieldChange={setField} + onFormPatch={patch => setForm(f => ({ ...f, ...patch }))} + pfuRates={pfuRates} + comptesInvestisseur={comptesInvestisseur} + investissements={investissements} + plateformes={plateformes} + investissementOptions={invListForSelect} + multiDetenteur={multiDetenteur} + /> ); })()} {openMenu && (