diff --git a/frontend/src/components/RembFormModal.jsx b/frontend/src/components/RembFormModal.jsx
index a8a205d..f996d06 100644
--- a/frontend/src/components/RembFormModal.jsx
+++ b/frontend/src/components/RembFormModal.jsx
@@ -72,6 +72,19 @@ export default function RembFormModal({
multiDetenteur = false,
confirmingDelete, setConfirmingDelete,
submitting = false,
+ // Libellé du sélecteur du haut (17/09/26, cf. RemboursementsSimple.jsx — pages PE,
+ // qui n'ont pas de notion d'"investissement" mais réutilisent ce même sélecteur pour
+ // choisir entre les 3 types bonus_parrainage/bonus_plateforme/interets_plateforme).
+ investissementLabel = 'Investissement *',
+ // Deals PE éligibles à la plateforme sélectionnée (17/09/26, cf. RemboursementsSimple.jsx) :
+ // permet de rattacher une distribution bonus/intérêts à un deal précis pour son TVPI.
+ // Vide par défaut (aucun impact pour les pages crowdlending qui ne passent pas cette prop).
+ dealOptions = [],
+ // Force l'affichage de la section Frais même quand la plateforme n'a pas de
+ // frais_plateforme configuré (17/09/26, cf. RemboursementsSimple.jsx : référentiel
+ // crowdlending-only qu'un deal PE n'a aucune raison d'avoir renseigné — sans ce
+ // forçage, le champ "Frais TTC" disparaissait pour toute nouvelle distribution PE).
+ forceShowFrais = false,
}) {
const isBonus = !locked && BONUS_VALUES.includes(form.investissement_id);
const isInteretsPlateforme = !locked && form.investissement_id === INTERETS_PLATEFORME_VALUE;
@@ -104,7 +117,7 @@ export default function RembFormModal({
// l'accès même si la config de la plateforme change ensuite) — même logique que le bloc
// "Frais d'opération" de la fiche investissement, cf. project_frais_operation.md.
const showFraisSection = !isBonus
- && ((currentPlat?.frais_plateforme && currentPlat.frais_plateforme !== 'aucun') || Number(form.frais_ttc) > 0);
+ && (forceShowFrais || (currentPlat?.frais_plateforme && currentPlat.frais_plateforme !== 'aucun') || Number(form.frais_ttc) > 0);
const modalTitle = title ?? (editingId ? 'Modifier le remboursement' : (locked ? 'Saisir le remboursement' : 'Nouveau remboursement'));
@@ -163,7 +176,7 @@ export default function RembFormModal({
{/* ── Sélecteur d'investissement (mode non verrouillé uniquement) ── */}
{!locked && (
- Investissement *
+ {investissementLabel}
onFieldChange('investissement_id', v)}
@@ -183,6 +196,15 @@ export default function RembFormModal({
)}
+ {!locked && isPlateformeMode && dealOptions.length > 0 && (
+
+ Investissement PE (optionnel)
+ onFormPatch({ investissement_pe_id: e.target.value })}>
+ — Aucun (non rattaché à un deal) —
+ {dealOptions.map(d => {d.nom_deal} )}
+
+
+ )}
{!locked && isBonus && (
Date *
diff --git a/frontend/src/pages/RemboursementsSimple.jsx b/frontend/src/pages/RemboursementsSimple.jsx
index f81d7f2..aa2a3ca 100644
--- a/frontend/src/pages/RemboursementsSimple.jsx
+++ b/frontend/src/pages/RemboursementsSimple.jsx
@@ -3,7 +3,7 @@ import { useLocation, useNavigate } from 'react-router-dom';
import { api } from '../api.js';
import { useInvestisseur } from '../context/InvestisseurContext.jsx';
import { useWorkspace } from '../context/WorkspaceContext.jsx';
-import Modal from '../components/Modal.jsx';
+import RembFormModal from '../components/RembFormModal.jsx';
import ConfirmModal from '../components/ConfirmModal.jsx';
import EmptyState from '../components/EmptyState.jsx';
import PageIcon from '../components/PageIcon.jsx';
@@ -132,8 +132,32 @@ function ExportDropdown({ disabled, onCSV, onXLS, onJSON }) {
);
}
+// Sélecteur du haut de RembFormModal (17/09/26, cf. InvestissementSelect.jsx) : mêmes
+// 3 sentinelles que Remboursements.jsx crowdlending, en lieu et place d'un champ `type`
+// dédié — permet de réutiliser RembFormModal tel quel (même formulaire, même gestion de
+// la fiscalité) plutôt que de dupliquer sa logique, à la demande d'Olivier ("la même
+// chose que pour le crowdlending").
+const BONUS_VALUES = ['BONUS_PARRAINAGE', 'BONUS_PLATEFORME'];
+const INTERETS_PLATEFORME_VALUE = 'INTERETS_PLATEFORME';
+const BONUS_TYPE_MAP = { BONUS_PARRAINAGE: 'bonus_parrainage', BONUS_PLATEFORME: 'bonus_plateforme' };
+const TYPE_TO_SENTINEL = {
+ bonus_parrainage: 'BONUS_PARRAINAGE',
+ bonus_plateforme: 'BONUS_PLATEFORME',
+ interets_plateforme: INTERETS_PLATEFORME_VALUE,
+};
+
+// Helpers purs pour le calcul fiscal (mêmes formules que RembFormModal.jsx / Remboursements.jsx
+// crowdlending), dupliqués volontairement (pas d'état) : servent au setField() de cette page.
+const round2 = (n) => Math.round(n * 100) / 100;
+const getRatesForYearPe = (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 emptyRembForm = {
- type: 'bonus_plateforme',
+ investissement_id: 'BONUS_PLATEFORME',
bonus_plateforme_id: '',
bonus_investisseur_id: '',
// Lien optionnel vers un deal PE (investissements_pe, 17/09/26) : permet de
@@ -175,7 +199,7 @@ const emptyRembForm = {
* X-Workspace-Id (cf. workspaceScope.js, Étape 2a/2c du 17/09/26).
*/
export default function RemboursementsSimple() {
- const { activeId: investisseurId, investisseurs } = useInvestisseur();
+ const { activeId: investisseurId } = useInvestisseur();
const { active: activeWorkspace } = useWorkspace() || {};
const location = useLocation();
const navigate = useNavigate();
@@ -185,7 +209,8 @@ export default function RemboursementsSimple() {
const [deals, setDeals] = useState([]);
// Taux PFU par année (17/09/26, cf. project ask Olivier : aligner la gestion de la
// fiscalité PE sur celle de Remboursements.jsx crowdlending) — sert au calcul
- // automatique des prélèvements sociaux/forfaitaire, cf. RemboursementForm ci-dessous.
+ // automatique des prélèvements sociaux/forfaitaire dans setField() ci-dessous, passé
+ // à RembFormModal (composant partagé avec Remboursements.jsx crowdlending).
const [pfuRates, setPfuRates] = useState([]);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState(null);
@@ -194,6 +219,10 @@ export default function RemboursementsSimple() {
const [editingId, setEditingId] = useState(null);
const [form, setForm] = useState(emptyRembForm);
const [deleteTarget, setDeleteTarget] = useState(null);
+ // Confirmation de suppression inline dans RembFormModal (bouton "Supprimer" du
+ // formulaire lui-même) — distinct de deleteTarget/ConfirmModal ci-dessus, qui reste
+ // le flux de suppression depuis l'icône ✕ d'une ligne du tableau.
+ const [confirmingDelete, setConfirmingDelete] = useState(false);
const { run: runSubmit, pending: submitting } = useAsyncAction();
// Plateformes éligibles au workspace actif uniquement (GET /plateformes
@@ -258,12 +287,13 @@ export default function RemboursementsSimple() {
const firstId = deal ? deal.plateforme_id : (plats[0]?.id || '');
setForm({
...emptyRembForm,
- ...(deal ? { type: 'interets_plateforme' } : {}),
+ investissement_id: deal ? INTERETS_PLATEFORME_VALUE : emptyRembForm.investissement_id,
bonus_plateforme_id: firstId,
bonus_investisseur_id: investisseurForPlat(firstId),
investissement_pe_id: deal ? String(deal.id) : '',
});
setErr(null);
+ setConfirmingDelete(false);
setModalOpen(true);
};
@@ -304,7 +334,7 @@ export default function RemboursementsSimple() {
const openEdit = (r) => {
setEditingId(r.id);
setForm({
- type: r.type,
+ investissement_id: TYPE_TO_SENTINEL[r.type] || INTERETS_PLATEFORME_VALUE,
bonus_plateforme_id: String(r.bonus_plateforme_id || ''),
bonus_investisseur_id: String(r.bonus_investisseur_id || ''),
investissement_pe_id: r.investissement_pe_id ? String(r.investissement_pe_id) : '',
@@ -320,15 +350,72 @@ export default function RemboursementsSimple() {
prelev_forfaitaire: String(r.prelev_forfaitaire ?? ''),
});
setErr(null);
+ setConfirmingDelete(false);
setModalOpen(true);
};
- const close = () => { setModalOpen(false); setEditingId(null); setForm(emptyRembForm); setErr(null); };
+ const close = () => {
+ setModalOpen(false); setEditingId(null); setForm(emptyRembForm); setErr(null); setConfirmingDelete(false);
+ };
+
+ // Recalcule automatiquement taxe locale / prélèvements PFU à chaque changement d'un
+ // champ "moteur" — même logique que setField() dans Remboursements.jsx crowdlending
+ // (cf. RembFormModal.jsx, qui appelle onFieldChange pour ces mêmes clés). L'utilisateur
+ // garde la main pour corriger prelev_sociaux/prelev_forfaitaire ensuite : ces deux champs
+ // ne redéclenchent pas ce recalcul (passent par onFormPatch, pas par ici).
+ const setField = (key, value) => {
+ // Le sélecteur "Type *" (InvestissementSelect, partagé avec Remboursements.jsx
+ // crowdlending) inclut toujours une option vide "—" — pertinente côté crowdlending
+ // (état initial "rien choisi") mais jamais côté PE, qui n'a que 3 types valides et
+ // aucun état "aucun type" à gérer. On ignore ce choix plutôt que de laisser le
+ // formulaire basculer sur la branche "remboursement normal" de RembFormModal
+ // (Capital/Méthode de remboursement...), sans rapport avec un deal PE.
+ if (key === 'investissement_id' && value === '') return;
+ const next = { ...form, [key]: value };
+
+ if (key === 'interets_bruts_avant_local') {
+ const plat = plats.find(p => String(p.id) === String(next.bonus_plateforme_id));
+ const taux = plat?.fiscalite === 'avec_fiscalite_locale' ? (plat?.taux_fiscalite_locale || 0) : 0;
+ next.taxe_locale = round2(Number(value) * taux / 100);
+ next.interets_bruts = round2(Number(value) - next.taxe_locale);
+ }
+ if (key === 'taxe_locale') {
+ next.interets_bruts = round2(Number(next.interets_bruts_avant_local || 0) - Number(value));
+ }
+ if (['date_remb', 'interets_bruts', 'interets_bruts_avant_local', 'taxe_locale', 'frais_ttc'].includes(key)) {
+ const dateStr = key === 'date_remb' ? value : next.date_remb;
+ const base = round2((Number(next.interets_bruts) || 0) - (Number(next.frais_ttc) || 0));
+ const rates = getRatesForYearPe(dateStr, pfuRates);
+ if (rates) {
+ next.prelev_sociaux = round2(base * rates.prelev_sociaux / 100);
+ next.prelev_forfaitaire = round2(base * rates.impot_revenu / 100);
+ }
+ }
+ setForm(next);
+ };
+
+ // Changement de plateforme associée : réinitialise le deal PE lié (peut ne plus être
+ // éligible à la nouvelle plateforme) — passe par onFormPatch (pas setField), comme le
+ // Plateforme de RembFormModal qui construit lui-même son patch complet.
+ const onFormPatch = (patch) => {
+ setForm(f => {
+ const next = { ...f, ...patch };
+ if ('bonus_plateforme_id' in patch) next.investissement_pe_id = '';
+ return next;
+ });
+ };
+
+ const isBonusForm = BONUS_VALUES.includes(form.investissement_id);
+
+ // Deals PE de la plateforme sélectionnée dans le formulaire (RembFormModal,
+ // dealOptions) — un remboursement bonus/intérêts peut être rattaché à un deal
+ // précis, pour le calcul de son TVPI (cf. pages/InvestissementsPe.jsx).
+ const dealsForSelectedPlat = deals.filter(d => String(d.plateforme_id) === String(form.bonus_plateforme_id));
const submit = async (e) => {
e?.preventDefault?.(); setErr(null);
const base = {
- type: form.type,
+ type: isBonusForm ? BONUS_TYPE_MAP[form.investissement_id] : 'interets_plateforme',
bonus_plateforme_id: Number(form.bonus_plateforme_id),
bonus_investisseur_id: Number(form.bonus_investisseur_id) || undefined,
investissement_pe_id: Number(form.investissement_pe_id) || undefined,
@@ -336,8 +423,12 @@ export default function RemboursementsSimple() {
statut: form.statut,
notes: form.notes || undefined,
};
- const payload = form.type === 'interets_plateforme'
+ const payload = isBonusForm
? {
+ ...base,
+ cashback: Number(form.cashback) || 0,
+ }
+ : {
...base,
interets_bruts_avant_local: Number(form.interets_bruts_avant_local) || 0,
taxe_locale: Number(form.taxe_locale) || 0,
@@ -345,10 +436,6 @@ export default function RemboursementsSimple() {
frais_ttc: Number(form.frais_ttc) || 0,
prelev_sociaux: Number(form.prelev_sociaux) || 0,
prelev_forfaitaire: Number(form.prelev_forfaitaire) || 0,
- }
- : {
- ...base,
- cashback: Number(form.cashback) || 0,
};
try {
if (editingId) await api.put(`/remboursements/${editingId}`, payload);
@@ -358,6 +445,7 @@ export default function RemboursementsSimple() {
} catch (e) { setErr(e.message); }
};
+ // Suppression depuis l'icône ✕ d'une ligne du tableau (flux ConfirmModal existant).
const onDelete = async () => {
if (!deleteTarget) return;
try {
@@ -367,6 +455,15 @@ export default function RemboursementsSimple() {
} catch (e) { setErr(e.message); }
};
+ // Suppression depuis le bouton "Supprimer" du formulaire lui-même (confirmation inline
+ // de RembFormModal — même pattern que Remboursements.jsx crowdlending).
+ const onDeleteEditing = async () => {
+ if (!editingId) return;
+ await api.del(`/remboursements/${editingId}`);
+ close();
+ await load();
+ };
+
const sorted = useMemo(
() => [...rows].sort((a, b) => b.date_remb.localeCompare(a.date_remb) || b.id - a.id),
[rows],
@@ -489,24 +586,29 @@ export default function RemboursementsSimple() {
cta="Ajouter un remboursement"
to="/remboursements?new=1"
/>
-
- Annuler
- runSubmit(submit)}>
- {submitting ? 'Création…' : 'Créer'}
-
-
- }
- >
-
-
+ editingId={editingId}
+ form={form}
+ onFieldChange={setField}
+ onFormPatch={onFormPatch}
+ err={err}
+ onSubmit={e => runSubmit(submit, e)}
+ onDelete={() => runSubmit(onDeleteEditing)}
+ submitting={submitting}
+ confirmingDelete={confirmingDelete}
+ setConfirmingDelete={setConfirmingDelete}
+ pfuRates={pfuRates}
+ comptesInvestisseur={[]}
+ investissements={[]}
+ plateformes={plats}
+ investissementOptions={[]}
+ multiDetenteur={multiDetenteur}
+ investissementLabel="Type *"
+ dealOptions={dealsForSelectedPlat}
+ forceShowFrais
+ />
>
);
@@ -754,34 +856,29 @@ export default function RemboursementsSimple() {
)}
-
-
- {editingId && (
- { const target = { id: editingId }; close(); setDeleteTarget(target); }}>
- Supprimer
-
- )}
-
-
- Annuler
- runSubmit(submit)}>
- {submitting ? (editingId ? 'Enregistrement…' : 'Création…') : (editingId ? 'Enregistrer' : 'Créer')}
-
-
-
- }
- >
-
-
+ editingId={editingId}
+ form={form}
+ onFieldChange={setField}
+ onFormPatch={onFormPatch}
+ err={err}
+ onSubmit={e => runSubmit(submit, e)}
+ onDelete={() => runSubmit(onDeleteEditing)}
+ submitting={submitting}
+ confirmingDelete={confirmingDelete}
+ setConfirmingDelete={setConfirmingDelete}
+ pfuRates={pfuRates}
+ comptesInvestisseur={[]}
+ investissements={[]}
+ plateformes={plats}
+ investissementOptions={[]}
+ multiDetenteur={multiDetenteur}
+ investissementLabel="Type *"
+ dealOptions={dealsForSelectedPlat}
+ forceShowFrais
+ />
);
}
-
-/* ── Fiscalité (17/09/26, cf. Remboursements.jsx / RembFormModal.jsx crowdlending,
- * à la demande d'Olivier : la saisie d'une distribution PE doit s'appuyer sur les
- * mêmes taux PFU et la même logique indicatif/retenue-à-la-source, pas sur des champs
- * prélèvements en saisie libre). Helpers purs, dupliqués volontairement (pas d'état) —
- * même formules que RembFormModal.jsx. ── */
-const round2 = (n) => Math.round(n * 100) / 100;
-const fmtRatePfu = (n) => n != null ? n.toFixed(1).replace('.', ',') + ' %' : '…';
-const getRatesForYearPe = (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]);
-};
-
-/** Formulaire d'ajout/édition — factorisé pour être identique dans les deux
- * points d'entrée (page avec mouvements existants, et état vide). Champs
- * strictement alignés sur le Schema de backend/src/routes/remboursements.js,
- * limités aux types bonus_parrainage / bonus_plateforme / interets_plateforme
- * (le type "normal", lié à un investissement, reste crowdlending uniquement). */
-function RemboursementForm({ form, setForm, plats, investisseurs, deals, multiDetenteur, err, runSubmit, submit, investisseurForPlat, editingId, pfuRates }) {
- const isInterets = form.type === 'interets_plateforme';
-
- // Deals PE de la plateforme sélectionnée (un remboursement bonus/intérêts
- // peut être rattaché à un deal précis, pour le calcul de son TVPI — cf.
- // pages/InvestissementsPe.jsx). Optionnel : plusieurs plateformes n'ont
- // qu'un seul deal, ou aucun deal PE suivi pour l'instant.
- const dealsForPlat = deals.filter(d => String(d.plateforme_id) === String(form.bonus_plateforme_id));
-
- // ── Fiscalité (même logique que RembFormModal.jsx crowdlending) ────────
- const currentPlat = plats.find(p => String(p.id) === String(form.bonus_plateforme_id));
- const hasLocalTax = isInterets && currentPlat?.fiscalite === 'avec_fiscalite_locale' && currentPlat?.taux_fiscalite_locale;
- // Pas d'équivalent fiscalite_override côté investissements_pe (pas de notion
- // d'exonération par deal) : seule la fiscalité de la plateforme détermine le
- // caractère indicatif, contrairement à crowdlending qui croise aussi
- // investissement.fiscalite_override — cf. table investissements_pe (db/index.js).
- const isIndicatif = isInterets && Boolean(currentPlat && currentPlat.fiscalite !== 'flat_tax');
- const rates = isInterets ? getRatesForYearPe(form.date_remb, pfuRates) : null;
- const labelPS = `Prélèvements sociaux${rates ? ` (${fmtRatePfu(rates.prelev_sociaux)})` : ''} (€)`;
- const labelIR = `Impôt sur le revenu${rates ? ` (${fmtRatePfu(rates.impot_revenu)})` : ''} (€)`;
-
- // Recalcule automatiquement taxe locale / prélèvements PFU quand un champ
- // "moteur" change — l'utilisateur garde la main pour corriger prelev_sociaux/
- // prelev_forfaitaire ensuite (ces deux champs ne redéclenchent pas ce recalcul,
- // exactement comme setField()/onFormPatch() dans Remboursements.jsx).
- const setFiscalField = (key, value) => {
- const next = { ...form, [key]: value };
- if (key === 'interets_bruts_avant_local') {
- const taux = hasLocalTax ? (currentPlat.taux_fiscalite_locale || 0) : 0;
- next.taxe_locale = round2(Number(value) * taux / 100);
- next.interets_bruts = round2(Number(value) - next.taxe_locale);
- }
- if (key === 'taxe_locale') {
- next.interets_bruts = round2(Number(next.interets_bruts_avant_local || 0) - Number(value));
- }
- if (['date_remb', 'interets_bruts', 'interets_bruts_avant_local', 'taxe_locale', 'frais_ttc'].includes(key)) {
- const dateStr = key === 'date_remb' ? value : next.date_remb;
- const base = round2((Number(next.interets_bruts) || 0) - (Number(next.frais_ttc) || 0));
- const r = getRatesForYearPe(dateStr, pfuRates);
- if (r) {
- next.prelev_sociaux = round2(base * r.prelev_sociaux / 100);
- next.prelev_forfaitaire = round2(base * r.impot_revenu / 100);
- }
- }
- setForm(next);
- };
-
- const interetsNets = round2(
- (Number(form.interets_bruts) || 0) - (Number(form.frais_ttc) || 0)
- - (Number(form.prelev_sociaux) || 0) - (Number(form.prelev_forfaitaire) || 0)
- );
-
- const previewNet = () => {
- if (isInterets) {
- return isIndicatif
- ? round2((Number(form.interets_bruts) || 0) - (Number(form.frais_ttc) || 0))
- : interetsNets;
- }
- return Number(form.cashback) || 0;
- };
-
- return (
-
- );
-}