Amélioration de la logique des investissements vs dépôts

This commit is contained in:
ocroguennec committed 2026-08-29 16:03:55 +02:00
1 parent c7bbe5dee6
commit f6829dec9d
4 files changed
+349 -52

No files matched your search

+139 -51
View File
@@ -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}
/>
@@ -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 = () =>
+123 -1
View File
@@ -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 && (
<div style={{
position: 'fixed', inset: 0, zIndex: 1000,
display: 'flex', alignItems: 'center', justifyContent: 'center',
background: 'rgba(0,0,0,0.4)',
}}>
<div style={{
background: 'var(--surface)', borderRadius: 10,
padding: '24px 28px', width: 420, maxWidth: '90vw',
boxShadow: '0 8px 32px rgba(0,0,0,0.18)',
border: '1px solid var(--border)',
}}>
<div style={{ marginBottom: 6, fontWeight: 700, fontSize: 'var(--fs-base)' }}>
Porte-monnaie insuffisant
</div>
<div style={{ fontSize: 'var(--fs-sm)', color: 'var(--text-muted)', marginBottom: 18, lineHeight: 1.5 }}>
Une fois cet investissement pris en compte, le porte-monnaie de <strong>{depositPrompt.plateforme_nom}</strong> est
à découvert. Voulez-vous créer un dépôt pour le couvrir ?
</div>
<div style={{ marginBottom: 18 }}>
<label style={{ fontSize: 'var(--fs-sm)', fontWeight: 500, display: 'block', marginBottom: 4 }}>
Montant du dépôt (€)
</label>
<input
type="number" step="0.01" min="0"
value={depositPrompt.montant}
onChange={e => setDepositPrompt(p => ({ ...p, montant: e.target.value }))}
style={{ width: '100%', fontSize: 'var(--fs-base)' }}
autoFocus
/>
</div>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button onClick={() => {
const id = depositPrompt.investissementId;
setDepositPrompt(p => ({ ...p, open: false }));
navigate(`/investissements/${id}`);
}}>
Plus tard
</button>
<button
className="primary"
disabled={!depositPrompt.montant || Number(depositPrompt.montant) <= 0}
onClick={() => {
const { plateforme_id, montant } = depositPrompt;
setDepositPrompt(p => ({ ...p, open: false }));
navigate(`/depots-retraits?new=1&plateforme_id=${plateforme_id}&montant=${encodeURIComponent(montant)}&autoCreate=1`);
}}
>
Créer le dépôt
</button>
</div>
</div>
</div>
)}
</>
);
}