From f6829dec9dc75f2851e2288beff06cd795682ffb Mon Sep 17 00:00:00 2001 From: Olivier Date: Sat, 29 Aug 2026 16:03:55 +0200 Subject: [PATCH] =?UTF-8?q?Am=C3=A9lioration=20de=20la=20logique=20des=20i?= =?UTF-8?q?nvestissements=20vs=20d=C3=A9p=C3=B4ts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/InvestissementFormModal.jsx | 65 ++++++ frontend/src/pages/DepotsRetraits.jsx | 190 +++++++++++++----- frontend/src/pages/InvestissementDetail.jsx | 22 ++ frontend/src/pages/Investissements.jsx | 124 +++++++++++- 4 files changed, 349 insertions(+), 52 deletions(-) diff --git a/frontend/src/components/InvestissementFormModal.jsx b/frontend/src/components/InvestissementFormModal.jsx index d40e223..a3ff986 100644 --- a/frontend/src/components/InvestissementFormModal.jsx +++ b/frontend/src/components/InvestissementFormModal.jsx @@ -1,7 +1,10 @@ import { useRef, useState, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; import Modal from './Modal.jsx'; import InvSelect from './InvSelect.jsx'; import CountrySelect from './CountrySelect.jsx'; +import { api } from '../api.js'; +import { fmtEUR, today } from '../utils/format.js'; import { addMonthsFE, addMonthsFinDeMoisFE, lastDayOfMonthFE, dayOfMonth } from '../utils/dateEcheances.js'; /** @@ -23,6 +26,14 @@ import { addMonthsFE, addMonthsFinDeMoisFE, lastDayOfMonthFE, dayOfMonth } from * `required`) de l'étape affichée via `reportValidity()` avant d'avancer ; "Précédent" ne * valide rien. Le bouton Supprimer (édition) reste visible sur les 3 étapes. * + * Exception au principe "composant bête" ci-dessus : le solde du porte-monnaie de la + * plateforme sélectionnée est chargé directement par CE composant (GET /dashboard/ + * solde-historique) et affiché sous le champ Plateforme (étape 1), avec un bouton + * "Déposer" qui navigue vers /depots-retraits?new=1&plateforme_id=… (ouverture auto de la + * modale Nouveau dépôt, cf. DepotsRetraits.jsx). Comportement strictement identique pour + * les deux pages consommatrices, donc porté ici plutôt que dupliqué dans chacune. + * + * Props (suite) : * Props : * open, onClose — état d'ouverture * editingId — id en édition, falsy si création (contrôle le titre, @@ -92,6 +103,46 @@ export default function InvestissementFormModal({ const showFinDeMois = (form.type_remb === 'in_fine' || form.type_remb === 'differe') && dayOfMonth(form.date_premiere_echeance) > 27; + const navigate = useNavigate(); + const [soldePlateforme, setSoldePlateforme] = useState(null); + const [soldeLoading, setSoldeLoading] = useState(false); + + // Solde indicatif du porte-monnaie de la plateforme sélectionnée (jour même) — uniquement + // en création : à la création on choisit la plateforme en fonction du solde disponible, + // en édition l'investissement existe déjà donc l'indicateur n'a plus lieu d'être (demande + // Olivier 29/08/26). + useEffect(() => { + if (!open || editingId || !form.plateforme_id) { + setSoldePlateforme(null); + return; + } + let cancelled = false; + setSoldeLoading(true); + api.get('/dashboard/solde-historique', { plateforme_id: form.plateforme_id, date: today() }) + .then(r => { if (!cancelled) setSoldePlateforme(r?.solde ?? null); }) + .catch(() => { if (!cancelled) setSoldePlateforme(null); }) + .finally(() => { if (!cancelled) setSoldeLoading(false); }); + return () => { cancelled = true; }; + }, [open, editingId, form.plateforme_id]); + + const handleGoDeposit = () => { + // Sauvegarde la saisie en cours pour la restaurer au retour depuis Dépôts/Retraits + // (cf. DepotsRetraits.jsx maybeReturnToInvestissement + les effets "resumeInvestissement" + // dans Investissements.jsx / InvestissementDetail.jsx). + try { + sessionStorage.setItem('cl_investissement_draft', JSON.stringify({ + returnTo: window.location.pathname + window.location.search, + editingId: editingId || null, + form, + })); + } catch { + // sessionStorage indisponible (mode privé, quota…) — tant pis, pas de retour auto, + // le dépôt reste faisable normalement. + } + onClose(); + navigate(`/depots-retraits?new=1&plateforme_id=${form.plateforme_id}&resumeInvestissement=1`); + }; + const handleMethodeChange = (methode) => { const defaultCompte = methode === 'compte_courant' ? (comptesInvestisseur.find(c => c.type === 'compte_courant') ?? comptesInvestisseur[0]) @@ -261,6 +312,20 @@ export default function InvestissementFormModal({ {plats.map(p => )} + {!editingId && form.plateforme_id && ( +
+ + Porte-monnaie : {soldeLoading ? '…' : (soldePlateforme !== null ? fmtEUR(soldePlateforme) : '—')} + + +
+ )}
diff --git a/frontend/src/pages/DepotsRetraits.jsx b/frontend/src/pages/DepotsRetraits.jsx index 3515a05..f5f9510 100644 --- a/frontend/src/pages/DepotsRetraits.jsx +++ b/frontend/src/pages/DepotsRetraits.jsx @@ -653,30 +653,100 @@ export default function DepotsRetraits() { return plat?.investisseur_id ? String(plat.investisseur_id) : defaultInvestisseurId(); }; - /* Ouverture auto depuis le bouton global "Nouveau dépôt/retrait" */ + /* Ouverture auto depuis le bouton global "Nouveau dépôt/retrait", ou depuis le formulaire + investissement — soit le bouton "Déposer" (ajoute ?plateforme_id=… pour présélectionner + la plateforme plutôt que de retomber sur plats[0], et ?resumeInvestissement=1 pour + demander un retour automatique vers l'investissement une fois le dépôt créé — cf. + pendingReturnActive/maybeReturnToInvestissement plus bas), soit la proposition de dépôt + après création d'un investissement dont le porte-monnaie ne couvrait pas le montant + (ajoute ?montant=… pour pré-remplir le montant du dépôt, et ?autoCreate=1 pour créer le + dépôt directement sans repasser par la modale — l'utilisateur a déjà validé/rectifié le + montant dans la proposition affichée côté formulaire investissement). */ const pendingNew = useRef(false); + const pendingNewPlatId = useRef(null); + const pendingNewMontant = useRef(null); + const pendingNewAutoCreate = useRef(false); + const pendingReturnActive = useRef(false); useEffect(() => { - if (new URLSearchParams(search).get('new') !== '1') return; + const params = new URLSearchParams(search); + if (params.get('new') !== '1') return; + const platParam = params.get('plateforme_id'); + const montantParam = params.get('montant'); + const autoCreateParam = params.get('autoCreate') === '1'; + if (params.get('resumeInvestissement') === '1') pendingReturnActive.current = true; navigate('/depots-retraits', { replace: true }); if (plats.length > 0) { setEditingId(null); - const firstId = plats[0]?.id || ''; - setForm({ ...emptyMouvementForm, plateforme_id: firstId, investisseur_id: investisseurForPlat(firstId) }); - setErr(null); - setModalOpen(true); + const firstId = (platParam && plats.some(p => String(p.id) === platParam)) ? platParam : (plats[0]?.id || ''); + const built = { ...emptyMouvementForm, plateforme_id: firstId, investisseur_id: investisseurForPlat(firstId), montant: montantParam || '' }; + if (autoCreateParam && montantParam) { + setErr(null); + doCreateDeposit(built).catch(err => { + // Création auto échouée (réseau, validation serveur…) — on retombe sur la modale + // pré-remplie pour que l'utilisateur voie l'erreur et puisse retenter manuellement. + setForm(built); + setErr(err.message); + setModalOpen(true); + }); + } else { + setForm(built); + setErr(null); + setModalOpen(true); + } } else { pendingNew.current = true; + pendingNewPlatId.current = platParam; + pendingNewMontant.current = montantParam; + pendingNewAutoCreate.current = autoCreateParam; } }, [search]); // eslint-disable-line + /** Si le dépôt en cours de création vient du bouton "Déposer" du formulaire investissement + * (cf. ci-dessus), navigue vers la page d'origine avec la saisie restaurée depuis + * sessionStorage. Ne fait rien sinon (comportement normal, inchangé). À appeler après + * que le dépôt est effectivement créé (pas à l'annulation) — les 3 points de sortie + * possibles de la création (sans correction de solde / correction confirmée / correction + * "Passer") appellent tous cette fonction. */ + const maybeReturnToInvestissement = () => { + if (!pendingReturnActive.current) return; + pendingReturnActive.current = false; + try { + const raw = sessionStorage.getItem('cl_investissement_draft'); + if (!raw) return; + sessionStorage.removeItem('cl_investissement_draft'); + const draft = JSON.parse(raw); + if (!draft?.returnTo) return; + const sep = draft.returnTo.includes('?') ? '&' : '?'; + navigate(`${draft.returnTo}${sep}resumeInvestissement=1`); + } catch { + // sessionStorage/JSON invalide — pas de retour auto, on reste simplement sur cette page. + } + }; + useEffect(() => { if (!pendingNew.current || !plats.length) return; pendingNew.current = false; setEditingId(null); - const firstId = plats[0]?.id || ''; - setForm({ ...emptyMouvementForm, plateforme_id: firstId, investisseur_id: investisseurForPlat(firstId) }); - setErr(null); - setModalOpen(true); + const platParam = pendingNewPlatId.current; + pendingNewPlatId.current = null; + const montantParam = pendingNewMontant.current; + pendingNewMontant.current = null; + const autoCreateParam = pendingNewAutoCreate.current; + pendingNewAutoCreate.current = false; + const firstId = (platParam && plats.some(p => String(p.id) === platParam)) ? platParam : (plats[0]?.id || ''); + const built = { ...emptyMouvementForm, plateforme_id: firstId, investisseur_id: investisseurForPlat(firstId), montant: montantParam || '' }; + if (autoCreateParam && montantParam) { + setErr(null); + doCreateDeposit(built).catch(err => { + setForm(built); + setErr(err.message); + setModalOpen(true); + }); + } else { + setForm(built); + setErr(null); + setModalOpen(true); + } }, [plats]); // eslint-disable-line /* CRUD */ @@ -762,54 +832,71 @@ export default function DepotsRetraits() { const close = () => { setModalOpen(false); setEditingId(null); setForm(emptyMouvementForm); setErr(null); }; + /** Crée un dépôt/retrait à partir d'un objet form donné. Extrait de submit() pour être + * réutilisable par la création automatique déclenchée depuis ?new=1&autoCreate=1 (cf. + * effets ci-dessus) — l'utilisateur y a déjà validé/rectifié le montant en amont, donc pas + * besoin de repasser par la modale. Les erreurs remontent à l'appelant (try/catch). */ + const doCreateDeposit = async (f) => { + const payload = { + investisseur_id: Number(f.investisseur_id) || undefined, + plateforme_id: Number(f.plateforme_id), + date_operation: f.date_operation, + type: f.type, + montant: Number(f.montant), + libelle: f.libelle || undefined, + reference: f.reference || undefined, + notes: f.notes || undefined, + }; + await api.post('/depots-retraits', payload); + close(); + + // Vérifier si la plateforme est flat_tax → proposer correction de solde + const plat = plats.find(p => String(p.id) === String(f.plateforme_id)); + if (plat?.fiscalite === 'flat_tax') { + // Solde attendu = solde actuel ± montant de l'opération (avant reload) + const currentPlatData = dashData?.cashByPlatform?.find( + p => String(p.plateforme_id) === String(f.plateforme_id) + ); + const currentBalance = currentPlatData?.solde_portefeuille ?? 0; + const montant = Number(f.montant); + const expectedBalance = Math.round( + (f.type === 'retrait' ? currentBalance - montant : currentBalance + montant) * 100 + ) / 100; + + setCorrModal({ + open: true, + plateforme_nom: plat.nom, + type_operation: f.type, + computedBalance: expectedBalance, + date: f.date_operation, + platId: Number(f.plateforme_id), + investisseurId: Number(f.investisseur_id) || null, + }); + } else { + await Promise.all([load(), loadDashDataAsOf()]); + maybeReturnToInvestissement(); + } + }; + const submit = async (e) => { e?.preventDefault?.(); setErr(null); try { - const payload = { - investisseur_id: Number(form.investisseur_id) || undefined, - plateforme_id: Number(form.plateforme_id), - date_operation: form.date_operation, - type: form.type, - montant: Number(form.montant), - libelle: form.libelle || undefined, - reference: form.reference || undefined, - notes: form.notes || undefined, - }; if (editingId) { + const payload = { + investisseur_id: Number(form.investisseur_id) || undefined, + plateforme_id: Number(form.plateforme_id), + date_operation: form.date_operation, + type: form.type, + montant: Number(form.montant), + libelle: form.libelle || undefined, + reference: form.reference || undefined, + notes: form.notes || undefined, + }; await api.put(`/depots-retraits/${editingId}`, payload); close(); await Promise.all([load(), loadDashDataAsOf()]); return; } - - // Nouvelle opération - await api.post('/depots-retraits', payload); - close(); - - // Vérifier si la plateforme est flat_tax → proposer correction de solde - const plat = plats.find(p => String(p.id) === String(form.plateforme_id)); - if (plat?.fiscalite === 'flat_tax') { - // Solde attendu = solde actuel ± montant de l'opération (avant reload) - const currentPlatData = dashData?.cashByPlatform?.find( - p => String(p.plateforme_id) === String(form.plateforme_id) - ); - const currentBalance = currentPlatData?.solde_portefeuille ?? 0; - const montant = Number(form.montant); - const expectedBalance = Math.round( - (form.type === 'retrait' ? currentBalance - montant : currentBalance + montant) * 100 - ) / 100; - - setCorrModal({ - open: true, - plateforme_nom: plat.nom, - type_operation: form.type, - computedBalance: expectedBalance, - date: form.date_operation, - platId: Number(form.plateforme_id), - investisseurId: Number(form.investisseur_id) || null, - }); - } else { - await Promise.all([load(), loadDashDataAsOf()]); - } + await doCreateDeposit(form); } catch (e) { setErr(e.message); } }; @@ -831,6 +918,7 @@ export default function DepotsRetraits() { } setCorrModal(m => ({ ...m, open: false })); await Promise.all([load(), loadDashDataAsOf()]); + maybeReturnToInvestissement(); }; const openRowMenu = (e, row) => { @@ -1623,7 +1711,7 @@ export default function DepotsRetraits() { type_operation={corrModal.type_operation} computedBalance={corrModal.computedBalance} date={corrModal.date} - onClose={() => { setCorrModal(m => ({ ...m, open: false })); load(); }} + onClose={() => { setCorrModal(m => ({ ...m, open: false })); load(); maybeReturnToInvestissement(); }} onConfirm={handleCorrectionConfirm} /> diff --git a/frontend/src/pages/InvestissementDetail.jsx b/frontend/src/pages/InvestissementDetail.jsx index 02a6993..b1d6c6a 100644 --- a/frontend/src/pages/InvestissementDetail.jsx +++ b/frontend/src/pages/InvestissementDetail.jsx @@ -223,6 +223,28 @@ export default function InvestissementDetail() { /* eslint-disable-next-line */ }, [inv, pfuRates, searchParams.get('remb-date')]); + // Reprise de la saisie après un aller-retour "Déposer" vers Dépôts/Retraits (bouton dans + // InvestissementFormModal, étape 1) : la modale d'édition se rouvre avec exactement ce + // qui avait été saisi avant le dépôt. + useEffect(() => { + if (searchParams.get('resumeInvestissement') !== '1' || !inv) return; + setSearchParams(p => { p.delete('resumeInvestissement'); return p; }, { replace: true }); + try { + const raw = sessionStorage.getItem('cl_investissement_draft'); + if (raw) { + const draft = JSON.parse(raw); + sessionStorage.removeItem('cl_investissement_draft'); + if (draft?.form) { + setForm(draft.form); + lastValidSouscriptionRef.current = draft.form.date_souscription || ''; + setErr(null); + setModalOpen(true); + } + } + } catch { /* ignore */ } + /* eslint-disable-next-line */ + }, [inv, searchParams.get('resumeInvestissement')]); + // ── Helpers PFU / calculs remboursement ──────────────────────── const round2 = n => Math.round(n * 100) / 100; const getLastKnownRates = () => diff --git a/frontend/src/pages/Investissements.jsx b/frontend/src/pages/Investissements.jsx index 78c978e..ff56cf0 100644 --- a/frontend/src/pages/Investissements.jsx +++ b/frontend/src/pages/Investissements.jsx @@ -293,6 +293,11 @@ export default function Investissements() { const [loading, setLoading] = useState(false); const [deleteConfirm, setDeleteConfirm] = useState(null); const [openMenu, setOpenMenu] = useState(null); + // Proposition de dépôt après création d'un investissement si le porte-monnaie ne + // couvre pas l'opération (contrôle création uniquement, cf. submit()). + const [depositPrompt, setDepositPrompt] = useState({ + open: false, plateforme_id: null, plateforme_nom: '', montant: 0, investissementId: null, + }); /* ── Chargement (sans filtre — filtrage côté client) ── */ const load = async () => { @@ -625,6 +630,40 @@ export default function Investissements() { setModalOpen(true); }, [plats]); /* eslint-disable-next-line */ + /* ── Reprise de la saisie après un aller-retour "Déposer" vers Dépôts/Retraits (bouton + dans InvestissementFormModal, étape 1) : la modale se rouvre avec exactement ce qui + avait été saisi avant le dépôt. */ + const pendingResume = useRef(null); + useEffect(() => { + if (searchParams.get('resumeInvestissement') !== '1') return; + const n = new URLSearchParams(searchParams); n.delete('resumeInvestissement'); + navigate({ pathname: '/investissements', search: n.toString() ? '?' + n.toString() : '' }, { replace: true }); + let draft = null; + try { + const raw = sessionStorage.getItem('cl_investissement_draft'); + if (raw) { draft = JSON.parse(raw); sessionStorage.removeItem('cl_investissement_draft'); } + } catch { /* ignore */ } + if (!draft?.form) return; + if (plats.length > 0) { + setEditingId(draft.editingId || null); + setForm(draft.form); + setErr(null); + setModalOpen(true); + } else { + pendingResume.current = draft; + } + }, [searchParams]); /* eslint-disable-next-line */ + + useEffect(() => { + if (!pendingResume.current || !plats.length) return; + const draft = pendingResume.current; + pendingResume.current = null; + setEditingId(draft.editingId || null); + setForm(draft.form); + setErr(null); + setModalOpen(true); + }, [plats]); /* eslint-disable-next-line */ + /* ── CRUD ── */ const openNew = () => { setEditingId(null); @@ -747,8 +786,34 @@ export default function Investissements() { api.put(`/investissements/${created.id}/categories-inv`, { ids: form.categories_inv_ids || [] }), api.put(`/investissements/${created.id}/secteurs-inv`, { ids: form.secteurs_inv_ids || [] }), ]); + + // Contrôle du porte-monnaie — création uniquement (jamais en édition). Le solde + // recalculé APRÈS création tient déjà compte du capital de ce nouvel investissement + // (formule officielle, cf. mémoire project_solde_portefeuille) : s'il ressort négatif, + // le porte-monnaie ne couvrait pas l'opération. On propose alors un dépôt du montant + // du découvert (rectifiable par l'utilisateur) plutôt que de bloquer la création. + let soldeApres = null; + try { + const r = await api.get('/dashboard/solde-historique', { plateforme_id: form.plateforme_id, date: today() }); + soldeApres = r?.solde ?? null; + } catch { + // Contrôle indisponible — n'empêche pas la création pour autant. + } + close(); - navigate(`/investissements/${created.id}`); + + if (soldeApres !== null && soldeApres < -0.005) { + const plat = plats.find(p => String(p.id) === String(form.plateforme_id)); + setDepositPrompt({ + open: true, + plateforme_id: Number(form.plateforme_id), + plateforme_nom: plat?.nom || '', + montant: Math.round(Math.abs(soldeApres) * 100) / 100, + investissementId: created.id, + }); + } else { + navigate(`/investissements/${created.id}`); + } } } catch (e) { setErr(e.message); } }; @@ -1335,6 +1400,63 @@ export default function Investissements() { onConfirm={deleteConfirm?.onConfirm} onCancel={() => setDeleteConfirm(null)} /> + + {/* ── Proposition de dépôt si le porte-monnaie ne couvrait pas le nouvel + investissement (cf. submit()) — montant rectifiable avant de créer le dépôt. ── */} + {depositPrompt.open && ( +
+
+
+ Porte-monnaie insuffisant +
+
+ Une fois cet investissement pris en compte, le porte-monnaie de {depositPrompt.plateforme_nom} est + à découvert. Voulez-vous créer un dépôt pour le couvrir ? +
+
+ + setDepositPrompt(p => ({ ...p, montant: e.target.value }))} + style={{ width: '100%', fontSize: 'var(--fs-base)' }} + autoFocus + /> +
+
+ + +
+
+
+ )} ); }