From 0df335803e872688cad09cd8d2462550c6ce8e7b Mon Sep 17 00:00:00 2001 From: Olivier Date: Fri, 18 Sep 2026 07:48:55 +0200 Subject: [PATCH] MAJ --- frontend/src/components/RembFormModal.jsx | 26 +- frontend/src/pages/RemboursementsSimple.jsx | 436 +++++++------------- 2 files changed, 176 insertions(+), 286 deletions(-) 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 && (
- + onFieldChange('investissement_id', v)} @@ -183,6 +196,15 @@ export default function RembFormModal({
)} + {!locked && isPlateformeMode && dealOptions.length > 0 && ( +
+ + +
+ )} {!locked && isBonus && (
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 + // setForm({ ...form, type: e.target.value })}> - - - - -
-
- - -
- {dealsForPlat.length > 0 && ( -
- - -
- )} -
- - setForm({ ...form, date_remb: e.target.value })} /> -
- - {!isInterets && ( -
- - setForm({ ...form, cashback: e.target.value })} /> -
- )} - - {isInterets && ( - <> - {hasLocalTax && ( -
- - setFiscalField('interets_bruts_avant_local', e.target.value)} /> -
- )} - {hasLocalTax && ( -
- - setFiscalField('taxe_locale', e.target.value)} /> -
- )} -
- - !hasLocalTax && setFiscalField('interets_bruts', e.target.value)} /> -
-
- - setFiscalField('frais_ttc', e.target.value)} /> -
- -
- - Imposition{isIndicatif ? ' — indicatif' : ''} - -
-
- - !isIndicatif && setForm({ ...form, prelev_sociaux: e.target.value })} /> -
-
- - !isIndicatif && setForm({ ...form, prelev_forfaitaire: e.target.value })} /> -
-
- - -
-
- {isIndicatif - ? "L'imposition n'est pas déduite du montant net perçu (fiscalité appliquée à titre indicatif — plateforme hors flat tax)." - : "L'imposition est déduite du montant net perçu (intérêts bruts − frais − prélèvements)."} -
- - )} - -
- - -
-
- - -
-
- - setForm({ ...form, notes: e.target.value })} /> -
- {!isInterets && ( -
- Net perçu estimé : {fmtEUR(previewNet())} -
- )} - - - ); -}