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 && (
+
+
+
+
+ Projet
+ Date
+ Erreur
+
+
+
+ {bulkErrors.map((e, i) => (
+
+ {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 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 (
+
+
+ {editingId && onDelete && (
+ !confirmingDelete ? (
+ setConfirmingDelete(true)}>
+ Supprimer
+
+ ) : (
+
+
+ Supprimer définitivement ?
+
+ setConfirmingDelete(false)}>Non
+ Oui, supprimer
+
+ )
+ )}
+
+ Annuler
+ {editingId ? 'Enregistrer' : 'Créer'}
+
+ }
+ >
+
+
+ );
+}
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 && (
- setConfirmingRembDelete(true)}>
- Supprimer
-
- )}
- {confirmingRembDelete && (
-
-
- Supprimer ce remboursement ?
-
- setConfirmingRembDelete(false)}>Non
- Oui, supprimer
-
- )}
- {!confirmingRembDelete && (
-
- Annuler
- Enregistrer
-
- )}
-
- }
- >
-
-
- );
- })()}
+ 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() {
setActiveTab('remboursements')}>Remboursements
+
setActiveTab('mois-en-cours')}>Mois en cours
setActiveTab('depots-retraits')}>Dépôts / Retraits
+ {moisEnCoursCell ? (
+
+
+
+ Mois en cours
+
+
+ {moisEnCoursCell.moisLabel} {moisEnCoursCell.annee}
+
+
+
+ filteredPlatIds.has(p.id))}
+ onBulkDone={load}
+ />
+
+
+ ) : (
+
+ Aucune plateforme sélectionnée.
+
+ )}
+
+ )}
+
{/* ====== 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
setActiveTab('vision-mensuelle')}>Vision mensuelle
+ setActiveTab('mois-en-cours')}>Mois en cours
setActiveTab('remboursements')}>Remboursements
)}
+ {/* ══ ONGLET MOIS EN COURS ══ */}
+ {activeTab === 'mois-en-cours' && (
+
+
+
+
+
+ )}
+
{/* ══ ONGLET REMBOURSEMENTS ══ */}
{activeTab === 'remboursements' && (
@@ -1409,12 +1444,6 @@ export default function Remboursements() {
{/* ══ 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
const invListForSelect = (editingId
? 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');
});
- // 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 (
-
-
- {editingId && (
- !confirmingDelete ? (
- setConfirmingDelete(true)}>
- Supprimer
-
- ) : (
-
-
- Supprimer définitivement ?
-
- setConfirmingDelete(false)}>Non
- Oui, supprimer
-
- )
- )}
-
- Annuler
- {editingId ? 'Enregistrer' : 'Créer'}
-
- }
- >
-
-
+ 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 && (