From c7bbe5dee6c2643bec9354c220a123258af27679 Mon Sep 17 00:00:00 2001 From: Olivier Date: Sat, 29 Aug 2026 15:14:02 +0200 Subject: [PATCH] =?UTF-8?q?Am=C3=A9lioration=20de=20la=20modale=20Investii?= =?UTF-8?q?sement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 + .../components/InvestissementFormModal.jsx | 485 ++++++++++++++++ frontend/src/pages/InvestissementDetail.jsx | 537 ++++-------------- frontend/src/pages/Investissements.jsx | 418 ++------------ frontend/src/utils/dateEcheances.js | 42 ++ ....timestamp-1788000428327-c073ded7c221b.mjs | 22 - 6 files changed, 698 insertions(+), 810 deletions(-) create mode 100644 frontend/src/components/InvestissementFormModal.jsx create mode 100644 frontend/src/utils/dateEcheances.js delete mode 100644 frontend/vite.config.js.timestamp-1788000428327-c073ded7c221b.mjs diff --git a/.gitignore b/.gitignore index 688b3d8..197dc34 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,10 @@ build/ */dist/ */build/ +# Résidus Vite (config transpilée temporairement lors de vite build/dev) +vite.config.js.timestamp-*.mjs +*/vite.config.js.timestamp-*.mjs + # Environment .env .env.local diff --git a/frontend/src/components/InvestissementFormModal.jsx b/frontend/src/components/InvestissementFormModal.jsx new file mode 100644 index 0000000..d40e223 --- /dev/null +++ b/frontend/src/components/InvestissementFormModal.jsx @@ -0,0 +1,485 @@ +import { useRef, useState, useEffect } from 'react'; +import Modal from './Modal.jsx'; +import InvSelect from './InvSelect.jsx'; +import CountrySelect from './CountrySelect.jsx'; +import { addMonthsFE, addMonthsFinDeMoisFE, lastDayOfMonthFE, dayOfMonth } from '../utils/dateEcheances.js'; + +/** + * InvestissementFormModal — formulaire partagé de création/édition d'un investissement. + * + * Utilisé par Investissements.jsx (création + édition depuis la liste) et + * InvestissementDetail.jsx (édition depuis la fiche). Comme RembFormModal, ce composant + * est volontairement "bête" côté état : le parent garde la propriété du form (state, + * effets de bord au changement de plateforme comme le préremplissage des catégories/secteurs + * ou la résolution du détenteur, chargement des comptes du détenteur…). Ce composant se + * contente des cascades de champs qui ne dépendent que du form lui-même (dates, durée, type + * de prêt, méthode de remboursement) et du rendu JSX. + * + * Depuis le 29/08/26, l'affichage se fait en 3 étapes (assistant) plutôt qu'en un seul + * écran : (1) Informations générales, (2) Données financières et échéancier, + * (3) Catégories, secteurs, autres. L'étape courante (`step`) est un état interne au + * composant, non exposé au parent, et se réinitialise à 1 à chaque ouverture de la modale + * (transition `open` false → true). "Suivant" valide les champs obligatoires (natifs + * `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. + * + * Props : + * open, onClose — état d'ouverture + * editingId — id en édition, falsy si création (contrôle le titre, + * l'affichage du champ Statut, et le libellé du bouton) + * form — objet form courant (voir `empty`/`emptyForm` des pages appelantes) + * onFieldChange(key, value) — pour les champs simples, sans effet de bord + * onFormPatch(patchOrUpdater) — pour les cascades (objet complet à fusionner, ou + * fonction (prev) => next pour les cas sensibles au Strict Mode) + * err — message d'erreur à afficher + * onSubmit — handler de soumission + * onDelete, confirmingDelete, setConfirmingDelete + * — flux de suppression optionnel (affiché si onDelete fourni) + * plats — liste des plateformes + * multiDetenteur — bool, affiche le détenteur de chaque plateforme dans le select + * investisseurs — liste des détenteurs (select Détenteur, avec optgroups Famille/Entreprises) + * comptesInvestisseur — comptes du détenteur courant (select Compte de réception, étape 2) + * onPlateformeChange(platformId) — délégué au parent : résolution du détenteur, méthode héritée, + * reset du compte, préremplissage catégories/secteurs + * categoriesInv, secteursInv — listes pour les sélecteurs Catégories/Secteurs d'investissement + * onCategoryAdded, onSectorAdded — callbacks après création d'une catégorie/d'un secteur via InvSelect + * tauxRevisionWarning — JSX optionnel affiché sous le champ Taux annuel (étape 2) + * dateCibleRevisionWarning — JSX optionnel affiché sous le champ Date cible (étape 2) + * restructurationBlock — JSX optionnel affiché après le champ Date cible, étape 2 (pleine largeur) + * width — largeur de la modale (défaut 720) + */ +export default function InvestissementFormModal({ + open, onClose, editingId, + form, onFieldChange, onFormPatch, + err, onSubmit, onDelete, confirmingDelete, setConfirmingDelete, + plats = [], multiDetenteur = false, + investisseurs = [], + comptesInvestisseur = [], + onPlateformeChange, + categoriesInv = [], secteursInv = [], + onCategoryAdded, onSectorAdded, + tauxRevisionWarning = null, + dateCibleRevisionWarning = null, + restructurationBlock = null, + width = 720, +}) { + // Vestige côté données : mémorise la dernière souscription valide saisie. + // N'est lu nulle part dans le code actuel — conservé tel quel (écriture sans effet) + // pour ne rien changer au comportement observable pendant le regroupement des deux formulaires. + const lastValidSouscriptionRef = useRef(''); + + const formRef = useRef(null); + const [step, setStep] = useState(1); + + // Repart de l'étape 1 à chaque (ré)ouverture de la modale, création comme édition. + useEffect(() => { + if (open) setStep(1); + }, [open]); + + const stepTitles = [ + "Informations générales de l'investissement", + 'Données financières et échéancier', + 'Catégories, secteurs, autres', + ]; + + const goNext = () => { + if (formRef.current && !formRef.current.reportValidity()) return; + setStep(s => Math.min(3, s + 1)); + }; + const goPrev = () => setStep(s => Math.max(1, s - 1)); + + const platMethode = plats.find(p => String(p.id) === String(form.plateforme_id))?.methode_remboursement; + const showFinDeMois = (form.type_remb === 'in_fine' || form.type_remb === 'differe') + && dayOfMonth(form.date_premiere_echeance) > 27; + + const handleMethodeChange = (methode) => { + const defaultCompte = methode === 'compte_courant' + ? (comptesInvestisseur.find(c => c.type === 'compte_courant') ?? comptesInvestisseur[0]) + : null; + onFormPatch({ methode_remboursement: methode, nom_compte_courant: defaultCompte?.nom || '', compte_id: String(defaultCompte?.id || '') }); + }; + + const handleCompteChange = (compteId) => { + const c = comptesInvestisseur.find(c => String(c.id) === compteId); + onFormPatch({ compte_id: compteId, nom_compte_courant: c?.nom || '' }); + }; + + const handleTypeRembChange = (t) => { + const next = { + ...form, type_remb: t, + freq_interets: t === 'differe' ? 'in_fine' : (form.freq_interets === 'in_fine' ? 'mensuel' : form.freq_interets), + }; + if (t === 'differe') { + // Recalculer la date d'échéance depuis souscription+durée (prioritaire sur l'ancienne valeur) + if (form.date_souscription && form.duree_mois) { + const dpe = addMonthsFE(form.date_souscription, 1); + const cible = addMonthsFE(dpe, Number(form.duree_mois) - 1); + next.date_premiere_echeance = cible; + next.date_cible = cible; + } else if (form.date_premiere_echeance) { + next.date_cible = form.date_premiere_echeance; + } + } else { + if (form.date_souscription) { + next.date_premiere_echeance = addMonthsFE(form.date_souscription, 1); + if (next.date_premiere_echeance && form.duree_mois) { + next.date_cible = addMonthsFE(next.date_premiere_echeance, Number(form.duree_mois) - 1); + } + } + } + onFormPatch(next); + }; + + const handleDureeChange = (duree) => { + const next = { ...form, duree_mois: duree }; + if (duree) { + if (form.type_remb === 'differe' && form.date_souscription) { + const tempDpe = addMonthsFE(form.date_souscription, 1); + const cible = addMonthsFE(tempDpe, Number(duree) - 1); + next.date_premiere_echeance = cible; + next.date_cible = cible; + } else if (form.date_premiere_echeance) { + next.date_cible = addMonthsFE(form.date_premiere_echeance, Number(duree) - 1); + } + } + onFormPatch(next); + }; + + const handleSouscriptionChange = (souscription) => { + onFormPatch(prev => { + const next = { ...prev, date_souscription: souscription }; + if (!souscription || souscription.length !== 10) return next; + if (prev.type_remb === 'differe') { + if (prev.duree_mois) { + const tempDpe = addMonthsFE(souscription, 1); + const cible = addMonthsFE(tempDpe, Number(prev.duree_mois) - 1); + next.date_premiere_echeance = cible; + next.date_cible = cible; + } + } else { + // Pour in_fine / amortissable : recalcul systématique de premiere_echeance + // (cohérent avec le comportement de "differe" et évite les faux négatifs + // du test isAutoCalc quand premiere_echeance ne vaut pas exactement souscription+1m) + next.date_premiere_echeance = addMonthsFE(souscription, 1); + if (next.date_premiere_echeance && prev.duree_mois) { + next.date_cible = addMonthsFE(next.date_premiere_echeance, Number(prev.duree_mois) - 1); + } + } + return next; + }); + if (souscription && souscription.length === 10) { + lastValidSouscriptionRef.current = souscription; + } + }; + + const handleDpeChange = (dpe) => { + const next = { ...form, date_premiere_echeance: dpe }; + // Si le jour passe à ≤ 27 on désactive fin-de-mois + if (dayOfMonth(dpe) <= 27) next.echeance_fin_de_mois = false; + if (form.type_remb === 'differe') { + next.date_cible = dpe; + } else if (dpe && form.duree_mois) { + next.date_cible = next.echeance_fin_de_mois + ? addMonthsFinDeMoisFE(dpe, Number(form.duree_mois) - 1) + : addMonthsFE(dpe, Number(form.duree_mois) - 1); + } + onFormPatch(next); + }; + + const handleFinDeMoisChange = (checked) => { + onFormPatch(prev => { + const next = { ...prev, echeance_fin_de_mois: checked }; + if (checked && prev.date_premiere_echeance) { + const newDpe = lastDayOfMonthFE(prev.date_premiere_echeance); + next.date_premiere_echeance = newDpe; + if (prev.type_remb === 'differe') { + next.date_cible = newDpe; + } else if (newDpe && prev.duree_mois) { + next.date_cible = addMonthsFinDeMoisFE(newDpe, Number(prev.duree_mois) - 1); + } + } + return next; + }); + }; + + return ( + +
+ {editingId && onDelete && ( + !confirmingDelete ? ( + + ) : ( + + + Supprimer définitivement ? + + + + + ) + )} +
+ {step === 1 && } + {step > 1 && } + {step < 3 && } + {step === 3 && } + + } + > + {/* ── Indicateur d'étape ── */} +
+
+ Étape {step} : {stepTitles[step - 1]} +
+
+ {[1, 2, 3].map(n => ( +
+ ))} +
+
+ +
+ {err &&
{err}
} +
+ + {/* ══ Étape 1 : Informations générales ══ */} + {step === 1 && (<> +
+ + +
+
+ + +
+
+ + onFieldChange('nom_projet', e.target.value)} /> +
+
+ + onFieldChange('emetteur', e.target.value)} + placeholder="Nom de l'emprunteur" + /> +
+
+ + onFieldChange('reference', e.target.value)} + placeholder="N° de dossier, code projet…" + /> +
+
+ + onFieldChange('pays_exposition', code)} + /> +
+ {editingId && ( +
+ + +
+ )} + )} + + {/* ══ Étape 2 : Données financières et échéancier ══ */} + {step === 2 && (<> +
+ + onFieldChange('montant_investi', e.target.value)} /> +
+
+ + onFieldChange('taux_interet', e.target.value)} /> + {tauxRevisionWarning} +
+
+ + handleDureeChange(e.target.value)} /> +
+
+ + +
+
+ + +
+
+ + handleSouscriptionChange(e.target.value)} /> +
+
+ + handleDpeChange(e.target.value)} /> + {/* ── Case "Dernier jour du mois" — visible si jour > 27 ── */} + {showFinDeMois && ( + + )} +
+
+ + { + if (form.type_remb === 'differe') return; + onFieldChange('date_cible', e.target.value); + }} + /> + {dateCibleRevisionWarning} +
+ {platMethode && (platMethode === 'choix_investisseur' ? ( +
+ + +
+ ) : ( +
+ + +
+ ))} + {form.methode_remboursement === 'compte_courant' && ( +
+ + {comptesInvestisseur.length > 0 ? ( + + ) : ( +

+ Aucun compte défini pour ce détenteur. Créer un compte → +

+ )} +
+ )} + {restructurationBlock} + )} + + {/* ══ Étape 3 : Catégories, secteurs, autres ══ */} + {step === 3 && (<> +
+ +