Amélioration de la modale Investiisement
This commit is contained in:
1 parent
44b8e5eda0
commit
c7bbe5dee6
6 files changed
+698
-810
No files matched your search
@@ -8,6 +8,10 @@ build/
|
|||||||
*/dist/
|
*/dist/
|
||||||
*/build/
|
*/build/
|
||||||
|
|
||||||
|
# Résidus Vite (config transpilée temporairement lors de vite build/dev)
|
||||||
|
vite.config.js.timestamp-*.mjs
|
||||||
|
*/vite.config.js.timestamp-*.mjs
|
||||||
|
|
||||||
# Environment
|
# Environment
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
title={editingId ? 'Modifier l\'investissement' : 'Nouvel investissement'}
|
||||||
|
onClose={onClose}
|
||||||
|
width={width}
|
||||||
|
footer={
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', width: '100%', gap: 8 }}>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
{editingId && onDelete && (
|
||||||
|
!confirmingDelete ? (
|
||||||
|
<button className="danger" onClick={() => setConfirmingDelete(true)}>
|
||||||
|
Supprimer
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<span style={{ fontSize: 13, color: 'var(--danger, #ef4444)', whiteSpace: 'nowrap' }}>
|
||||||
|
Supprimer définitivement ?
|
||||||
|
</span>
|
||||||
|
<button onClick={() => setConfirmingDelete(false)}>Non</button>
|
||||||
|
<button className="danger" onClick={onDelete}>Oui, supprimer</button>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{step === 1 && <button onClick={onClose}>Annuler</button>}
|
||||||
|
{step > 1 && <button onClick={goPrev}>Précédent</button>}
|
||||||
|
{step < 3 && <button className="primary" onClick={goNext}>Suivant</button>}
|
||||||
|
{step === 3 && <button className="primary" onClick={onSubmit}>{editingId ? 'Enregistrer' : 'Créer'}</button>}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{/* ── Indicateur d'étape ── */}
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 8 }}>
|
||||||
|
Étape {step} : {stepTitles[step - 1]}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 6 }}>
|
||||||
|
{[1, 2, 3].map(n => (
|
||||||
|
<div key={n} style={{
|
||||||
|
flex: 1, height: 5, borderRadius: 4,
|
||||||
|
background: n <= step ? 'var(--primary)' : 'var(--border)',
|
||||||
|
}} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form ref={formRef} onSubmit={onSubmit}>
|
||||||
|
{err && <div className="error">{err}</div>}
|
||||||
|
<div className="form-grid">
|
||||||
|
|
||||||
|
{/* ══ Étape 1 : Informations générales ══ */}
|
||||||
|
{step === 1 && (<>
|
||||||
|
<div>
|
||||||
|
<label>Plateforme *</label>
|
||||||
|
<select required value={form.plateforme_id} onChange={e => onPlateformeChange(e.target.value)}>
|
||||||
|
<option value="">—</option>
|
||||||
|
{plats.map(p => <option key={p.id} value={p.id}>{p.nom}{multiDetenteur && p.investisseur_nom ? ` — ${p.investisseur_nom}` : ''}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Détenteur *</label>
|
||||||
|
<select required value={form.investisseur_id} onChange={e => onFieldChange('investisseur_id', e.target.value)}>
|
||||||
|
<option value="">—</option>
|
||||||
|
{investisseurs.filter(i => i.type !== 'entreprise').length > 0 && (
|
||||||
|
<optgroup label="Famille">
|
||||||
|
{investisseurs.filter(i => i.type !== 'entreprise').map(i => (
|
||||||
|
<option key={i.id} value={i.id}>{i.nom}{i.is_principal ? ' (principal)' : ''}</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
)}
|
||||||
|
{investisseurs.filter(i => i.type === 'entreprise').length > 0 && (
|
||||||
|
<optgroup label="Entreprises">
|
||||||
|
{investisseurs.filter(i => i.type === 'entreprise').map(i => (
|
||||||
|
<option key={i.id} value={i.id}>{i.nom}</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div style={{ gridColumn: 'span 2' }}>
|
||||||
|
<label>Nom du projet *</label>
|
||||||
|
<input required value={form.nom_projet} onChange={e => onFieldChange('nom_projet', e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Émetteur</label>
|
||||||
|
<input value={form.emetteur}
|
||||||
|
onChange={e => onFieldChange('emetteur', e.target.value)}
|
||||||
|
placeholder="Nom de l'emprunteur"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Référence</label>
|
||||||
|
<input value={form.reference}
|
||||||
|
onChange={e => onFieldChange('reference', e.target.value)}
|
||||||
|
placeholder="N° de dossier, code projet…"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Pays d'exposition</label>
|
||||||
|
<CountrySelect
|
||||||
|
value={form.pays_exposition || 'FR'}
|
||||||
|
onChange={code => onFieldChange('pays_exposition', code)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{editingId && (
|
||||||
|
<div>
|
||||||
|
<label>Statut</label>
|
||||||
|
<select value={form.statut} onChange={e => onFieldChange('statut', e.target.value)}>
|
||||||
|
<option value="en_cours">En cours</option>
|
||||||
|
<option value="rembourse">Remboursé</option>
|
||||||
|
<option value="en_retard">En retard</option>
|
||||||
|
<option value="procedure">Procédure</option>
|
||||||
|
<option value="cloture">Clôturé</option>
|
||||||
|
<option value="perte_definitive">Perte définitive</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>)}
|
||||||
|
|
||||||
|
{/* ══ Étape 2 : Données financières et échéancier ══ */}
|
||||||
|
{step === 2 && (<>
|
||||||
|
<div>
|
||||||
|
<label>Montant investi (€) *</label>
|
||||||
|
<input type="number" step="0.01" min="0" required value={form.montant_investi}
|
||||||
|
onChange={e => onFieldChange('montant_investi', e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Taux annuel (%) *</label>
|
||||||
|
<input type="number" step="0.01" required value={form.taux_interet}
|
||||||
|
onChange={e => onFieldChange('taux_interet', e.target.value)} />
|
||||||
|
{tauxRevisionWarning}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Durée (mois) *</label>
|
||||||
|
<input type="number" min="0" required value={form.duree_mois} onChange={e => handleDureeChange(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Type de prêt</label>
|
||||||
|
<select value={form.type_remb} onChange={e => handleTypeRembChange(e.target.value)}>
|
||||||
|
<option value="in_fine">Prêt in fine</option>
|
||||||
|
<option value="amortissable">Prêt amortissable</option>
|
||||||
|
<option value="differe">Prêt différé</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Fréquence intérêts</label>
|
||||||
|
<select
|
||||||
|
value={form.type_remb === 'differe' ? 'in_fine' : form.freq_interets}
|
||||||
|
disabled={form.type_remb === 'differe'}
|
||||||
|
onChange={e => onFieldChange('freq_interets', e.target.value)}
|
||||||
|
>
|
||||||
|
{form.type_remb === 'differe' ? (
|
||||||
|
<option value="in_fine">In fine (unique)</option>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<option value="mensuel">Mensuel</option>
|
||||||
|
<option value="trimestriel">Trimestriel</option>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Date souscription *</label>
|
||||||
|
<input type="date" required value={form.date_souscription} onChange={e => handleSouscriptionChange(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>{form.type_remb === 'differe' ? "Date d'échéance (versement unique) *" : 'Date 1ère échéance *'}</label>
|
||||||
|
<input type="date" required value={form.date_premiere_echeance} onChange={e => handleDpeChange(e.target.value)} />
|
||||||
|
{/* ── Case "Dernier jour du mois" — visible si jour > 27 ── */}
|
||||||
|
{showFinDeMois && (
|
||||||
|
<label style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 8,
|
||||||
|
marginTop: 6, cursor: 'pointer', userSelect: 'none',
|
||||||
|
fontSize: 'var(--fs-sm)', color: 'var(--text)',
|
||||||
|
width: 'fit-content',
|
||||||
|
}}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
style={{ width: 'auto', flexShrink: 0 }}
|
||||||
|
checked={form.echeance_fin_de_mois}
|
||||||
|
onChange={e => handleFinDeMoisChange(e.target.checked)}
|
||||||
|
/>
|
||||||
|
Dernier jour du mois
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Date cible contractuelle{form.type_remb === 'differe' ? ' — calculée' : ''}</label>
|
||||||
|
<input type="date" value={form.date_cible}
|
||||||
|
readOnly={form.type_remb === 'differe'}
|
||||||
|
style={form.type_remb === 'differe' ? { opacity: 0.6, cursor: 'default' } : undefined}
|
||||||
|
onChange={e => {
|
||||||
|
if (form.type_remb === 'differe') return;
|
||||||
|
onFieldChange('date_cible', e.target.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{dateCibleRevisionWarning}
|
||||||
|
</div>
|
||||||
|
{platMethode && (platMethode === 'choix_investisseur' ? (
|
||||||
|
<div>
|
||||||
|
<label>Méthode de remboursement</label>
|
||||||
|
<select value={form.methode_remboursement} onChange={e => handleMethodeChange(e.target.value)}>
|
||||||
|
<option value="">— Non renseignée —</option>
|
||||||
|
<option value="portefeuille">Porte-monnaie de la plateforme</option>
|
||||||
|
<option value="compte_courant">Compte courant de l'investisseur</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<label>Méthode de remboursement</label>
|
||||||
|
<input readOnly value={platMethode === 'compte_courant' ? "Compte courant de l'investisseur" : 'Porte-monnaie de la plateforme'}
|
||||||
|
style={{ background: 'var(--surface-2)', color: 'var(--text-muted)', cursor: 'default' }} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{form.methode_remboursement === 'compte_courant' && (
|
||||||
|
<div>
|
||||||
|
<label>Compte de réception *</label>
|
||||||
|
{comptesInvestisseur.length > 0 ? (
|
||||||
|
<select required value={form.compte_id} onChange={e => handleCompteChange(e.target.value)}>
|
||||||
|
<option value="">— Choisir un compte —</option>
|
||||||
|
{comptesInvestisseur.map(c => (
|
||||||
|
<option key={c.id} value={c.id}>{c.nom}{c.banque ? ` — ${c.banque}` : ''}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<p className="text-muted" style={{ fontSize: 'var(--fs-sm)', margin: '4px 0' }}>
|
||||||
|
Aucun compte défini pour ce détenteur. <a href="/settings?section=comptes" target="_blank" rel="noreferrer">Créer un compte →</a>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{restructurationBlock}
|
||||||
|
</>)}
|
||||||
|
|
||||||
|
{/* ══ Étape 3 : Catégories, secteurs, autres ══ */}
|
||||||
|
{step === 3 && (<>
|
||||||
|
<div style={{ gridColumn: 'span 2' }}>
|
||||||
|
<label>Notes</label>
|
||||||
|
<textarea rows={3} value={form.notes}
|
||||||
|
onChange={e => onFieldChange('notes', e.target.value)}
|
||||||
|
placeholder="Informations complémentaires…"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Catégories d'investissement */}
|
||||||
|
<div style={{ gridColumn: 'span 3' }}>
|
||||||
|
<label>Catégories d'investissement</label>
|
||||||
|
<InvSelect
|
||||||
|
items={categoriesInv}
|
||||||
|
selected={form.categories_inv_ids || []}
|
||||||
|
onChange={ids => onFieldChange('categories_inv_ids', ids)}
|
||||||
|
addApiPath="/categories-inv"
|
||||||
|
onItemAdded={onCategoryAdded}
|
||||||
|
emptyLabel="Aucune catégorie d'investissement"
|
||||||
|
addLabel="Ajouter une catégorie d'investissement"
|
||||||
|
inputPlaceholder="Nom de la catégorie…"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Secteurs d'investissement */}
|
||||||
|
<div style={{ gridColumn: 'span 3' }}>
|
||||||
|
<label>Secteurs d'investissement</label>
|
||||||
|
<InvSelect
|
||||||
|
items={secteursInv}
|
||||||
|
selected={form.secteurs_inv_ids || []}
|
||||||
|
onChange={ids => onFieldChange('secteurs_inv_ids', ids)}
|
||||||
|
addApiPath="/secteurs-inv"
|
||||||
|
onItemAdded={onSectorAdded}
|
||||||
|
emptyLabel="Aucun secteur d'investissement"
|
||||||
|
addLabel="Ajouter un secteur d'investissement"
|
||||||
|
inputPlaceholder="Nom du secteur…"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,7 +6,9 @@ import { useUi } from '../context/UiContext.jsx';
|
|||||||
import Modal from '../components/Modal.jsx';
|
import Modal from '../components/Modal.jsx';
|
||||||
import ConfirmModal from '../components/ConfirmModal.jsx';
|
import ConfirmModal from '../components/ConfirmModal.jsx';
|
||||||
import RembFormModal from '../components/RembFormModal.jsx';
|
import RembFormModal from '../components/RembFormModal.jsx';
|
||||||
import CountrySelect, { COUNTRIES, FlagIcon } from '../components/CountrySelect.jsx';
|
import { COUNTRIES, FlagIcon } from '../components/CountrySelect.jsx';
|
||||||
|
import InvestissementFormModal from '../components/InvestissementFormModal.jsx';
|
||||||
|
import { dayOfMonth } from '../utils/dateEcheances.js';
|
||||||
import { fmtEUR, fmtPct, fmtDate, today } from '../utils/format.js';
|
import { fmtEUR, fmtPct, fmtDate, today } from '../utils/format.js';
|
||||||
import { xirr } from '../utils/xirr.js';
|
import { xirr } from '../utils/xirr.js';
|
||||||
|
|
||||||
@@ -18,6 +20,7 @@ const emptyForm = {
|
|||||||
statut: 'en_cours', reference: '', notes: '', pays_exposition: 'FR',
|
statut: 'en_cours', reference: '', notes: '', pays_exposition: 'FR',
|
||||||
echeance_fin_de_mois: false,
|
echeance_fin_de_mois: false,
|
||||||
methode_remboursement: '', nom_compte_courant: '', compte_id: '',
|
methode_remboursement: '', nom_compte_courant: '', compte_id: '',
|
||||||
|
categories_inv_ids: [], secteurs_inv_ids: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -27,38 +30,6 @@ const emptyForm = {
|
|||||||
* - 2+ → première de la liste (pas d'historique disponible sur la page détail)
|
* - 2+ → première de la liste (pas d'historique disponible sur la page détail)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function addMonthsFE(isoDate, n) {
|
|
||||||
if (!isoDate || !n) return '';
|
|
||||||
const [y, m, d] = isoDate.split('-').map(Number);
|
|
||||||
const dt = new Date(Date.UTC(y, m - 1 + n, d));
|
|
||||||
return dt.toISOString().slice(0, 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Retourne le dernier jour du mois de isoDate. Ex : '2024-01-28' → '2024-01-31' */
|
|
||||||
function lastDayOfMonthFE(isoDate) {
|
|
||||||
if (!isoDate) return isoDate;
|
|
||||||
const [y, m] = isoDate.split('-').map(Number);
|
|
||||||
const dt = new Date(Date.UTC(y, m, 0));
|
|
||||||
return dt.toISOString().slice(0, 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ajoute n mois et positionne sur le dernier jour du mois cible.
|
|
||||||
* Évite les débordements JS (31 jan + 1 = 3 mar → ici = 29 fév).
|
|
||||||
*/
|
|
||||||
function addMonthsFinDeMoisFE(isoDate, n) {
|
|
||||||
if (!isoDate || (n === undefined || n === null)) return '';
|
|
||||||
const [y, m] = isoDate.split('-').map(Number);
|
|
||||||
const dt = new Date(Date.UTC(y, m - 1 + Number(n) + 1, 0));
|
|
||||||
return dt.toISOString().slice(0, 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Retourne le numéro du jour dans le mois (1-31) ou 0 si absent. */
|
|
||||||
function dayOfMonth(isoDate) {
|
|
||||||
if (!isoDate || isoDate.length < 10) return 0;
|
|
||||||
return parseInt(isoDate.slice(8, 10), 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
function typeLabel(type_remb, freq_interets) {
|
function typeLabel(type_remb, freq_interets) {
|
||||||
if (type_remb === 'in_fine') return `In fine · ${freq_interets === 'trimestriel' ? 'Trimestriel' : 'Mensuel'}`;
|
if (type_remb === 'in_fine') return `In fine · ${freq_interets === 'trimestriel' ? 'Trimestriel' : 'Mensuel'}`;
|
||||||
if (type_remb === 'amortissable') return `Amortissable · ${freq_interets === 'trimestriel' ? 'Trimestriel' : 'Mensuel'}`;
|
if (type_remb === 'amortissable') return `Amortissable · ${freq_interets === 'trimestriel' ? 'Trimestriel' : 'Mensuel'}`;
|
||||||
@@ -91,6 +62,8 @@ export default function InvestissementDetail() {
|
|||||||
const [plats, setPlats] = useState([]);
|
const [plats, setPlats] = useState([]);
|
||||||
const [comptesCourants, setComptesCourants] = useState([]);
|
const [comptesCourants, setComptesCourants] = useState([]);
|
||||||
const [comptesInvestisseur, setComptesInvestisseur] = useState([]);
|
const [comptesInvestisseur, setComptesInvestisseur] = useState([]);
|
||||||
|
const [categoriesInv, setCategoriesInv] = useState([]);
|
||||||
|
const [secteursInv, setSecteursInv] = useState([]);
|
||||||
const [comptesRembInvestisseur, setComptesRembInvestisseur] = useState([]);
|
const [comptesRembInvestisseur, setComptesRembInvestisseur] = useState([]);
|
||||||
const [pfuRates, setPfuRates] = useState([]);
|
const [pfuRates, setPfuRates] = useState([]);
|
||||||
// Modal édition investissement
|
// Modal édition investissement
|
||||||
@@ -185,14 +158,18 @@ export default function InvestissementDetail() {
|
|||||||
const load = async () => {
|
const load = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const [data, p, cats, comptes] = await Promise.all([
|
const [data, p, comptes, catInv, sectInv] = await Promise.all([
|
||||||
api.get(`/investissements/${id}`),
|
api.get(`/investissements/${id}`),
|
||||||
api.get('/plateformes'),
|
api.get('/plateformes'),
|
||||||
api.get('/investissements/comptes-courants'),
|
api.get('/investissements/comptes-courants'),
|
||||||
|
api.get('/categories-inv'),
|
||||||
|
api.get('/secteurs-inv'),
|
||||||
]);
|
]);
|
||||||
setInv(data);
|
setInv(data);
|
||||||
setPlats(p);
|
setPlats(p);
|
||||||
setComptesCourants(comptes);
|
setComptesCourants(comptes);
|
||||||
|
setCategoriesInv(catInv);
|
||||||
|
setSecteursInv(sectInv);
|
||||||
} finally { setLoading(false); }
|
} finally { setLoading(false); }
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -555,12 +532,42 @@ export default function InvestissementDetail() {
|
|||||||
nom_compte_courant: inv.nom_compte_courant || '',
|
nom_compte_courant: inv.nom_compte_courant || '',
|
||||||
compte_id: inv.compte_id || '',
|
compte_id: inv.compte_id || '',
|
||||||
pays_exposition: inv.pays_exposition || 'FR',
|
pays_exposition: inv.pays_exposition || 'FR',
|
||||||
|
categories_inv_ids: (inv.categories_inv || []).map(c => c.id),
|
||||||
|
secteurs_inv_ids: (inv.secteurs_inv || []).map(s => s.id),
|
||||||
});
|
});
|
||||||
lastValidSouscriptionRef.current = inv.date_souscription || '';
|
lastValidSouscriptionRef.current = inv.date_souscription || '';
|
||||||
setErr(null);
|
setErr(null);
|
||||||
setModalOpen(true);
|
setModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Changement de plateforme dans le formulaire : résout la méthode de remboursement héritée
|
||||||
|
* (le détenteur, lui, reste au choix de l'utilisateur — pas d'écrasement automatique en édition)
|
||||||
|
* et préremplit catégories/secteurs depuis la plateforme. */
|
||||||
|
const handlePlateformeChange = (platId) => {
|
||||||
|
const platObj = plats.find(p => String(p.id) === String(platId));
|
||||||
|
const platMethode = platObj?.methode_remboursement;
|
||||||
|
const newMethode = platMethode === 'choix_investisseur' ? '' : (platMethode || '');
|
||||||
|
if (platId) {
|
||||||
|
Promise.all([
|
||||||
|
api.get(`/plateformes/${platId}/categories-inv`).catch(() => []),
|
||||||
|
api.get(`/plateformes/${platId}/secteurs-inv`).catch(() => []),
|
||||||
|
]).then(([platCats, platSects]) => {
|
||||||
|
setForm(f => ({ ...f,
|
||||||
|
plateforme_id: platId,
|
||||||
|
investisseur_id: platObj?.investisseur_id ? String(platObj.investisseur_id) : f.investisseur_id,
|
||||||
|
methode_remboursement: newMethode, compte_id: '',
|
||||||
|
categories_inv_ids: platCats.map(c => c.id),
|
||||||
|
secteurs_inv_ids: platSects.map(s => s.id),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setForm(f => ({ ...f, plateforme_id: platId, methode_remboursement: newMethode, compte_id: '' }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCategoryAdded = (cat) => setCategoriesInv(prev => [...prev, cat].sort((a, b) => a.nom.localeCompare(b.nom)));
|
||||||
|
const handleSectorAdded = (sect) => setSecteursInv(prev => [...prev, sect].sort((a, b) => a.nom.localeCompare(b.nom)));
|
||||||
|
|
||||||
const close = () => { setModalOpen(false); setErr(null); setConfirmingInvDelete(false); };
|
const close = () => { setModalOpen(false); setErr(null); setConfirmingInvDelete(false); };
|
||||||
|
|
||||||
const submit = async (e) => {
|
const submit = async (e) => {
|
||||||
@@ -572,7 +579,7 @@ export default function InvestissementDetail() {
|
|||||||
|
|
||||||
// Validation date d'échéance pour les prêts différés
|
// Validation date d'échéance pour les prêts différés
|
||||||
if (form.type_remb === 'differe' && !form.date_premiere_echeance) {
|
if (form.type_remb === 'differe' && !form.date_premiere_echeance) {
|
||||||
setEditErr("La date d'échéance est requise pour un prêt différé.");
|
setErr("La date d'échéance est requise pour un prêt différé. Vérifiez que la durée et la date de souscription sont renseignées.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -600,6 +607,10 @@ export default function InvestissementDetail() {
|
|||||||
pays_exposition: form.pays_exposition || 'FR',
|
pays_exposition: form.pays_exposition || 'FR',
|
||||||
};
|
};
|
||||||
await api.put(`/investissements/${id}`, payload);
|
await api.put(`/investissements/${id}`, payload);
|
||||||
|
await Promise.all([
|
||||||
|
api.put(`/investissements/${id}/categories-inv`, { ids: form.categories_inv_ids || [] }),
|
||||||
|
api.put(`/investissements/${id}/secteurs-inv`, { ids: form.secteurs_inv_ids || [] }),
|
||||||
|
]);
|
||||||
close();
|
close();
|
||||||
await load();
|
await load();
|
||||||
} catch (e) { setErr(e.message); }
|
} catch (e) { setErr(e.message); }
|
||||||
@@ -2111,393 +2122,87 @@ export default function InvestissementDetail() {
|
|||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
{/* ── Modal édition ─────────────────────────────────────────── */}
|
{/* ── Modal édition ─────────────────────────────────────────── */}
|
||||||
<Modal
|
<InvestissementFormModal
|
||||||
open={modalOpen}
|
open={modalOpen}
|
||||||
title="Modifier l'investissement"
|
editingId={id}
|
||||||
onClose={close}
|
onClose={close}
|
||||||
width={720}
|
onSubmit={submit}
|
||||||
footer={
|
onDelete={onDelete}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', width: '100%', gap: 8 }}>
|
confirmingDelete={confirmingInvDelete}
|
||||||
{/* Zone suppression — à gauche */}
|
setConfirmingDelete={setConfirmingInvDelete}
|
||||||
<div style={{ flex: 1 }}>
|
form={form}
|
||||||
{!confirmingInvDelete ? (
|
onFieldChange={(key, value) => setForm(f => ({ ...f, [key]: value }))}
|
||||||
<button className="danger" onClick={() => setConfirmingInvDelete(true)}>
|
onFormPatch={patch => setForm(f => typeof patch === 'function' ? patch(f) : { ...f, ...patch })}
|
||||||
Supprimer
|
err={err}
|
||||||
|
plats={plats}
|
||||||
|
multiDetenteur={multiDetenteur}
|
||||||
|
investisseurs={investisseurs}
|
||||||
|
comptesInvestisseur={comptesInvestisseur}
|
||||||
|
onPlateformeChange={handlePlateformeChange}
|
||||||
|
categoriesInv={categoriesInv}
|
||||||
|
secteursInv={secteursInv}
|
||||||
|
onCategoryAdded={handleCategoryAdded}
|
||||||
|
onSectorAdded={handleSectorAdded}
|
||||||
|
tauxRevisionWarning={lastTauxRevision && (
|
||||||
|
<div style={{ fontSize: 11, color: '#a78bfa', marginTop: 4, lineHeight: 1.4 }}>
|
||||||
|
⚠ Taux révisé le {fmtDate(lastTauxRevision.date_effet)}. Une modification ici ne sera pas tracée
|
||||||
|
comme une révision — utilisez « Réviser les conditions » pour un changement lié à un événement.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
dateCibleRevisionWarning={lastDateCibleRevision && (
|
||||||
|
<div style={{ fontSize: 11, color: '#a78bfa', marginTop: 4, lineHeight: 1.4 }}>
|
||||||
|
⚠ Date cible révisée le {fmtDate(lastDateCibleRevision.date_effet)}. Une modification ici ne sera pas tracée
|
||||||
|
comme une révision — utilisez « Réviser les conditions » pour un changement lié à un événement.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
restructurationBlock={remb.length > 0 && (form.type_remb !== inv.type_remb || !!inv.date_debut_simul) && (
|
||||||
|
<div style={{ gridColumn: '1 / -1' }}>
|
||||||
|
<div style={{
|
||||||
|
borderLeft: '3px solid var(--warning, #f59e0b)',
|
||||||
|
background: 'rgba(245,158,11,0.08)',
|
||||||
|
borderRadius: '0 6px 6px 0',
|
||||||
|
padding: '10px 14px',
|
||||||
|
marginBottom: 2,
|
||||||
|
}}>
|
||||||
|
<label style={{ color: 'var(--warning, #f59e0b)', fontWeight: 600, marginBottom: 4 }}>
|
||||||
|
⟳ Restructuration — Début de la nouvelle simulation
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={form.date_debut_simul}
|
||||||
|
min={form.date_premiere_echeance || undefined}
|
||||||
|
max={form.date_cible || undefined}
|
||||||
|
onChange={e => setForm({ ...form, date_debut_simul: e.target.value })}
|
||||||
|
/>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 6, lineHeight: 1.5 }}>
|
||||||
|
La projection démarrera à cette date avec le nouveau type de prêt. La durée restante
|
||||||
|
sera calculée automatiquement à partir de la date de 1ère échéance d'origine.
|
||||||
|
{form.date_debut_simul && form.date_premiere_echeance && form.duree_mois && (() => {
|
||||||
|
const [y1, m1] = form.date_premiere_echeance.split('-').map(Number);
|
||||||
|
const [y2, m2] = form.date_debut_simul.split('-').map(Number);
|
||||||
|
const elapsed = (y2 - y1) * 12 + (m2 - m1);
|
||||||
|
const restant = Math.max(1, Number(form.duree_mois) - elapsed);
|
||||||
|
return elapsed > 0
|
||||||
|
? <strong style={{ display: 'block', marginTop: 4 }}>
|
||||||
|
→ {elapsed} mois écoulés · {restant} mois simulés
|
||||||
|
</strong>
|
||||||
|
: null;
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
{form.date_debut_simul && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
style={{ marginTop: 8, fontSize: 11, padding: '2px 10px' }}
|
||||||
|
onClick={() => setForm({ ...form, date_debut_simul: '' })}
|
||||||
|
>
|
||||||
|
Effacer la restructuration
|
||||||
</button>
|
</button>
|
||||||
) : (
|
|
||||||
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
||||||
<span style={{ fontSize: 13, color: 'var(--danger, #ef4444)', whiteSpace: 'nowrap' }}>
|
|
||||||
Supprimer définitivement ?
|
|
||||||
</span>
|
|
||||||
<button onClick={() => setConfirmingInvDelete(false)}>Non</button>
|
|
||||||
<button className="danger" onClick={() => { onDelete(); }}>Oui, supprimer</button>
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* Actions standard — à droite */}
|
|
||||||
<button onClick={close}>Annuler</button>
|
|
||||||
<button className="primary" onClick={submit}>Enregistrer</button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<form onSubmit={submit}>
|
|
||||||
{err && <div className="error">{err}</div>}
|
|
||||||
<div className="form-grid">
|
|
||||||
<div>
|
|
||||||
<label>Plateforme *</label>
|
|
||||||
<select required value={form.plateforme_id} onChange={e => {
|
|
||||||
const platId = e.target.value;
|
|
||||||
const platObj = plats.find(p => String(p.id) === String(platId));
|
|
||||||
const platMethode = platObj?.methode_remboursement;
|
|
||||||
const newMethode = platMethode === 'choix_investisseur' ? '' : (platMethode || '');
|
|
||||||
setForm(f => ({ ...f, plateforme_id: platId, methode_remboursement: newMethode, compte_id: '' }));
|
|
||||||
}}>
|
|
||||||
<option value="">—</option>
|
|
||||||
{plats.map(p => <option key={p.id} value={p.id}>{p.nom}{multiDetenteur && p.investisseur_nom ? ` — ${p.investisseur_nom}` : ''}</option>)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
{(() => {
|
|
||||||
const platMethode = plats.find(p => String(p.id) === String(form.plateforme_id))?.methode_remboursement;
|
|
||||||
if (!platMethode) return null;
|
|
||||||
if (platMethode === 'choix_investisseur') return (
|
|
||||||
<div>
|
|
||||||
<label>Méthode de remboursement</label>
|
|
||||||
<select value={form.methode_remboursement} onChange={e => {
|
|
||||||
const methode = e.target.value;
|
|
||||||
const defaultCompte = methode === 'compte_courant'
|
|
||||||
? (comptesInvestisseur.find(c => c.type === 'compte_courant') ?? comptesInvestisseur[0])
|
|
||||||
: null;
|
|
||||||
setForm({ ...form, methode_remboursement: methode, nom_compte_courant: defaultCompte?.nom || '', compte_id: String(defaultCompte?.id || '') });
|
|
||||||
}}>
|
|
||||||
<option value="">— Non renseignée —</option>
|
|
||||||
<option value="portefeuille">Porte-monnaie de la plateforme</option>
|
|
||||||
<option value="compte_courant">Compte courant de l'investisseur</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<label>Méthode de remboursement</label>
|
|
||||||
<input readOnly value={platMethode === 'compte_courant' ? "Compte courant de l'investisseur" : 'Porte-monnaie de la plateforme'}
|
|
||||||
style={{ background: 'var(--surface-2)', color: 'var(--text-muted)', cursor: 'default' }} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
{form.methode_remboursement === 'compte_courant' && (
|
|
||||||
<div>
|
|
||||||
<label>Compte de réception *</label>
|
|
||||||
{comptesInvestisseur.length > 0 ? (
|
|
||||||
<select
|
|
||||||
required
|
|
||||||
value={form.compte_id}
|
|
||||||
onChange={e => {
|
|
||||||
const c = comptesInvestisseur.find(c => String(c.id) === e.target.value);
|
|
||||||
setForm({ ...form, compte_id: e.target.value, nom_compte_courant: c?.nom || '' });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="">— Choisir un compte —</option>
|
|
||||||
{comptesInvestisseur.map(c => (
|
|
||||||
<option key={c.id} value={c.id}>{c.nom}{c.banque ? ` — ${c.banque}` : ''}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
) : (
|
|
||||||
<p className="text-muted" style={{ fontSize: 'var(--fs-sm)', margin: '4px 0' }}>
|
|
||||||
Aucun compte défini pour ce détenteur. <a href="/settings?section=comptes" target="_blank" rel="noreferrer">Créer un compte →</a>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div>
|
|
||||||
<label>Détenteur *</label>
|
|
||||||
<select required value={form.investisseur_id} onChange={e => setForm({ ...form, investisseur_id: e.target.value })}>
|
|
||||||
<option value="">—</option>
|
|
||||||
{investisseurs.filter(i => i.type !== 'entreprise').length > 0 && (
|
|
||||||
<optgroup label="Famille">
|
|
||||||
{investisseurs.filter(i => i.type !== 'entreprise').map(i => (
|
|
||||||
<option key={i.id} value={i.id}>
|
|
||||||
{i.nom}{i.is_principal ? ' (principal)' : ''}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</optgroup>
|
|
||||||
)}
|
|
||||||
{investisseurs.filter(i => i.type === 'entreprise').length > 0 && (
|
|
||||||
<optgroup label="Entreprises">
|
|
||||||
{investisseurs.filter(i => i.type === 'entreprise').map(i => (
|
|
||||||
<option key={i.id} value={i.id}>{i.nom}</option>
|
|
||||||
))}
|
|
||||||
</optgroup>
|
|
||||||
)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div style={{ gridColumn: 'span 2' }}>
|
|
||||||
<label>Nom du projet *</label>
|
|
||||||
<input required value={form.nom_projet} onChange={e => setForm({ ...form, nom_projet: e.target.value })} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label>Type de prêt</label>
|
|
||||||
<select value={form.type_remb} onChange={e => {
|
|
||||||
const t = e.target.value;
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setForm(next);
|
|
||||||
}}>
|
|
||||||
<option value="in_fine">Prêt in fine</option>
|
|
||||||
<option value="amortissable">Prêt amortissable</option>
|
|
||||||
<option value="differe">Prêt différé</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label>Fréquence intérêts</label>
|
|
||||||
<select
|
|
||||||
value={form.type_remb === 'differe' ? 'in_fine' : form.freq_interets}
|
|
||||||
disabled={form.type_remb === 'differe'}
|
|
||||||
onChange={e => setForm({ ...form, freq_interets: e.target.value })}
|
|
||||||
>
|
|
||||||
{form.type_remb === 'differe' ? (
|
|
||||||
<option value="in_fine">In fine (unique)</option>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<option value="mensuel">Mensuel</option>
|
|
||||||
<option value="trimestriel">Trimestriel</option>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label>Durée (mois)</label>
|
|
||||||
<input type="number" min="0" value={form.duree_mois} onChange={e => {
|
|
||||||
const duree = e.target.value;
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setForm(next);
|
|
||||||
}} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label>Taux annuel (%)</label>
|
|
||||||
<input type="number" step="0.01" value={form.taux_interet} onChange={e => setForm({ ...form, taux_interet: e.target.value })} />
|
|
||||||
{lastTauxRevision && (
|
|
||||||
<div style={{ fontSize: 11, color: '#a78bfa', marginTop: 4, lineHeight: 1.4 }}>
|
|
||||||
⚠ Taux révisé le {fmtDate(lastTauxRevision.date_effet)}. Une modification ici ne sera pas tracée
|
|
||||||
comme une révision — utilisez « Réviser les conditions » pour un changement lié à un événement.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label>Montant investi (€) *</label>
|
|
||||||
<input type="number" step="0.01" min="0" required value={form.montant_investi} onChange={e => setForm({ ...form, montant_investi: e.target.value })} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label>Date souscription *</label>
|
|
||||||
<input type="date" required value={form.date_souscription} onChange={e => {
|
|
||||||
const souscription = e.target.value;
|
|
||||||
setForm(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;
|
|
||||||
});
|
|
||||||
// Mémorise la dernière souscription complète HORS setForm (évite les doubles appels Strict Mode)
|
|
||||||
if (souscription && souscription.length === 10) {
|
|
||||||
lastValidSouscriptionRef.current = souscription;
|
|
||||||
}
|
|
||||||
}} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label>Date 1ère échéance</label>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={form.date_premiere_echeance}
|
|
||||||
onChange={e => {
|
|
||||||
const dpe = e.target.value;
|
|
||||||
const next = { ...form, date_premiere_echeance: dpe };
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
setForm(next);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{/* ── Case "Dernier jour du mois" — visible si jour > 27 ── */}
|
|
||||||
{(form.type_remb === 'in_fine' || form.type_remb === 'differe') &&
|
|
||||||
dayOfMonth(form.date_premiere_echeance) > 27 && (
|
|
||||||
<label style={{
|
|
||||||
display: 'inline-flex', alignItems: 'center', gap: 8,
|
|
||||||
marginTop: 6, cursor: 'pointer', userSelect: 'none',
|
|
||||||
fontSize: 'var(--fs-sm)', color: 'var(--text)',
|
|
||||||
width: 'fit-content',
|
|
||||||
}}>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
style={{ width: 'auto', flexShrink: 0 }}
|
|
||||||
checked={form.echeance_fin_de_mois}
|
|
||||||
onChange={e => {
|
|
||||||
const checked = e.target.checked;
|
|
||||||
setForm(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;
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
Dernier jour du mois
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label>Date cible contractuelle{form.type_remb === 'differe' ? ' — calculée' : ''}</label>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={form.date_cible}
|
|
||||||
readOnly={form.type_remb === 'differe'}
|
|
||||||
style={form.type_remb === 'differe' ? { opacity: 0.6, cursor: 'default' } : undefined}
|
|
||||||
onChange={e => {
|
|
||||||
if (form.type_remb === 'differe') return;
|
|
||||||
setForm({ ...form, date_cible: e.target.value });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{lastDateCibleRevision && (
|
|
||||||
<div style={{ fontSize: 11, color: '#a78bfa', marginTop: 4, lineHeight: 1.4 }}>
|
|
||||||
⚠ Date cible révisée le {fmtDate(lastDateCibleRevision.date_effet)}. Une modification ici ne sera pas tracée
|
|
||||||
comme une révision — utilisez « Réviser les conditions » pour un changement lié à un événement.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{/* Champ restructuration : visible uniquement si des remboursements existent
|
|
||||||
ET que le type de prêt a été modifié (ou qu'une date était déjà définie) */}
|
|
||||||
{remb.length > 0 && (form.type_remb !== inv.type_remb || !!inv.date_debut_simul) && (
|
|
||||||
<div style={{ gridColumn: '1 / -1' }}>
|
|
||||||
<div style={{
|
|
||||||
borderLeft: '3px solid var(--warning, #f59e0b)',
|
|
||||||
background: 'rgba(245,158,11,0.08)',
|
|
||||||
borderRadius: '0 6px 6px 0',
|
|
||||||
padding: '10px 14px',
|
|
||||||
marginBottom: 2,
|
|
||||||
}}>
|
|
||||||
<label style={{ color: 'var(--warning, #f59e0b)', fontWeight: 600, marginBottom: 4 }}>
|
|
||||||
⟳ Restructuration — Début de la nouvelle simulation
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={form.date_debut_simul}
|
|
||||||
min={form.date_premiere_echeance || undefined}
|
|
||||||
max={form.date_cible || undefined}
|
|
||||||
onChange={e => setForm({ ...form, date_debut_simul: e.target.value })}
|
|
||||||
/>
|
|
||||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 6, lineHeight: 1.5 }}>
|
|
||||||
La projection démarrera à cette date avec le nouveau type de prêt. La durée restante
|
|
||||||
sera calculée automatiquement à partir de la date de 1ère échéance d'origine.
|
|
||||||
{form.date_debut_simul && form.date_premiere_echeance && form.duree_mois && (() => {
|
|
||||||
const [y1, m1] = form.date_premiere_echeance.split('-').map(Number);
|
|
||||||
const [y2, m2] = form.date_debut_simul.split('-').map(Number);
|
|
||||||
const elapsed = (y2 - y1) * 12 + (m2 - m1);
|
|
||||||
const restant = Math.max(1, Number(form.duree_mois) - elapsed);
|
|
||||||
return elapsed > 0
|
|
||||||
? <strong style={{ display: 'block', marginTop: 4 }}>
|
|
||||||
→ {elapsed} mois écoulés · {restant} mois simulés
|
|
||||||
</strong>
|
|
||||||
: null;
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
{form.date_debut_simul && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
style={{ marginTop: 8, fontSize: 11, padding: '2px 10px' }}
|
|
||||||
onClick={() => setForm({ ...form, date_debut_simul: '' })}
|
|
||||||
>
|
|
||||||
Effacer la restructuration
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div>
|
|
||||||
<label>Statut</label>
|
|
||||||
<select value={form.statut} onChange={e => setForm({ ...form, statut: e.target.value })}>
|
|
||||||
<option value="en_cours">En cours</option>
|
|
||||||
<option value="rembourse">Remboursé</option>
|
|
||||||
<option value="en_retard">En retard</option>
|
|
||||||
<option value="procedure">Procédure</option>
|
|
||||||
<option value="cloture">Clôturé</option>
|
|
||||||
<option value="perte_definitive">Perte définitive</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label>Référence (ID plateforme)</label>
|
|
||||||
<input value={form.reference} onChange={e => setForm({ ...form, reference: e.target.value })} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label>Pays d'exposition</label>
|
|
||||||
<CountrySelect
|
|
||||||
value={form.pays_exposition || 'FR'}
|
|
||||||
onChange={code => setForm(f => ({ ...f, pays_exposition: code }))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label>Émetteur</label>
|
|
||||||
<input value={form.emetteur} onChange={e => setForm({ ...form, emetteur: e.target.value })} />
|
|
||||||
</div>
|
|
||||||
<div style={{ gridColumn: '1 / -1' }}>
|
|
||||||
<label>Notes</label>
|
|
||||||
<textarea rows={2} value={form.notes} onChange={e => setForm({ ...form, notes: e.target.value })} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
)}
|
||||||
</Modal>
|
width={720}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* ── Modal saisie remboursement (depuis projection) ────────── */}
|
{/* ── Modal saisie remboursement (depuis projection) ────────── */}
|
||||||
<RembFormModal
|
<RembFormModal
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ import Pagination from '../components/Pagination.jsx';
|
|||||||
import PageIcon from '../components/PageIcon.jsx';
|
import PageIcon from '../components/PageIcon.jsx';
|
||||||
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
|
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import { api } from '../api.js';
|
import { api } from '../api.js';
|
||||||
import InvSelect from '../components/InvSelect.jsx';
|
import InvestissementFormModal from '../components/InvestissementFormModal.jsx';
|
||||||
import { useInvestisseur } from '../context/InvestisseurContext.jsx';
|
import { useInvestisseur } from '../context/InvestisseurContext.jsx';
|
||||||
import { useUi } from '../context/UiContext.jsx';
|
import { useUi } from '../context/UiContext.jsx';
|
||||||
import Modal from '../components/Modal.jsx';
|
import Modal from '../components/Modal.jsx';
|
||||||
import ConfirmModal from '../components/ConfirmModal.jsx';
|
import ConfirmModal from '../components/ConfirmModal.jsx';
|
||||||
import CountrySelect from '../components/CountrySelect.jsx';
|
import { dayOfMonth } from '../utils/dateEcheances.js';
|
||||||
import InvChart from '../components/InvChart.jsx';
|
import InvChart from '../components/InvChart.jsx';
|
||||||
import DistributionChart from '../components/DistributionChart.jsx';
|
import DistributionChart from '../components/DistributionChart.jsx';
|
||||||
import CapitalMensuelTable from '../components/CapitalMensuelTable.jsx';
|
import CapitalMensuelTable from '../components/CapitalMensuelTable.jsx';
|
||||||
@@ -241,38 +241,6 @@ const empty = {
|
|||||||
categories_inv_ids: [], secteurs_inv_ids: [],
|
categories_inv_ids: [], secteurs_inv_ids: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
function addMonthsFE(isoDate, n) {
|
|
||||||
if (!isoDate || !n) return '';
|
|
||||||
const [y, m, d] = isoDate.split('-').map(Number);
|
|
||||||
const dt = new Date(Date.UTC(y, m - 1 + n, d));
|
|
||||||
return dt.toISOString().slice(0, 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Retourne le dernier jour du mois de isoDate. Ex : '2024-01-28' → '2024-01-31' */
|
|
||||||
function lastDayOfMonthFE(isoDate) {
|
|
||||||
if (!isoDate) return isoDate;
|
|
||||||
const [y, m] = isoDate.split('-').map(Number);
|
|
||||||
const dt = new Date(Date.UTC(y, m, 0)); // jour 0 du mois suivant
|
|
||||||
return dt.toISOString().slice(0, 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ajoute n mois et positionne sur le dernier jour du mois cible.
|
|
||||||
* Évite les débordements JS (31 jan + 1 = 3 mar → ici = 29 fév).
|
|
||||||
*/
|
|
||||||
function addMonthsFinDeMoisFE(isoDate, n) {
|
|
||||||
if (!isoDate || (n === undefined || n === null)) return '';
|
|
||||||
const [y, m] = isoDate.split('-').map(Number);
|
|
||||||
const dt = new Date(Date.UTC(y, m - 1 + Number(n) + 1, 0));
|
|
||||||
return dt.toISOString().slice(0, 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Retourne le numéro du jour dans le mois (1-31) ou 0 si absent. */
|
|
||||||
function dayOfMonth(isoDate) {
|
|
||||||
if (!isoDate || isoDate.length < 10) return 0;
|
|
||||||
return parseInt(isoDate.slice(8, 10), 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Composant principal ─────────────────────────────────────── */
|
/* ── Composant principal ─────────────────────────────────────── */
|
||||||
export default function Investissements() {
|
export default function Investissements() {
|
||||||
const { activeId, activeView, investisseurs } = useInvestisseur();
|
const { activeId, activeView, investisseurs } = useInvestisseur();
|
||||||
@@ -700,6 +668,33 @@ export default function Investissements() {
|
|||||||
setModalOpen(true);
|
setModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Changement de plateforme dans le formulaire : résout la méthode de remboursement,
|
||||||
|
* le détenteur par défaut, et préremplit catégories/secteurs depuis la plateforme. */
|
||||||
|
const handlePlateformeChange = (platId) => {
|
||||||
|
const platObj = plats.find(p => String(p.id) === String(platId));
|
||||||
|
const platMethode = platObj?.methode_remboursement;
|
||||||
|
const newMethode = platMethode === 'choix_investisseur' ? '' : (platMethode || '');
|
||||||
|
if (platId) {
|
||||||
|
Promise.all([
|
||||||
|
api.get(`/plateformes/${platId}/categories-inv`).catch(() => []),
|
||||||
|
api.get(`/plateformes/${platId}/secteurs-inv`).catch(() => []),
|
||||||
|
]).then(([platCats, platSects]) => {
|
||||||
|
setForm(f => ({ ...f,
|
||||||
|
plateforme_id: platId,
|
||||||
|
investisseur_id: investisseurForPlat(platId),
|
||||||
|
methode_remboursement: newMethode, compte_id: '',
|
||||||
|
categories_inv_ids: platCats.map(c => c.id),
|
||||||
|
secteurs_inv_ids: platSects.map(s => s.id),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setForm(f => ({ ...f, plateforme_id: platId, investisseur_id: investisseurForPlat(platId), methode_remboursement: newMethode, compte_id: '', categories_inv_ids: [], secteurs_inv_ids: [] }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCategoryAdded = (cat) => setCategoriesInv(prev => [...prev, cat].sort((a, b) => a.nom.localeCompare(b.nom)));
|
||||||
|
const handleSectorAdded = (sect) => setSecteursInv(prev => [...prev, sect].sort((a, b) => a.nom.localeCompare(b.nom)));
|
||||||
|
|
||||||
const close = () => { setModalOpen(false); setEditingId(null); setForm(empty); setErr(null); };
|
const close = () => { setModalOpen(false); setEditingId(null); setForm(empty); setErr(null); };
|
||||||
|
|
||||||
const submit = async (e) => {
|
const submit = async (e) => {
|
||||||
@@ -1282,347 +1277,26 @@ export default function Investissements() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Modal création / édition ── */}
|
{/* ── Modal création / édition ── */}
|
||||||
<Modal
|
<InvestissementFormModal
|
||||||
open={modalOpen}
|
open={modalOpen}
|
||||||
title={editingId ? 'Modifier l\'investissement' : 'Nouvel investissement'}
|
editingId={editingId}
|
||||||
onClose={close}
|
onClose={close}
|
||||||
|
onSubmit={submit}
|
||||||
|
form={form}
|
||||||
|
onFieldChange={(key, value) => setForm(f => ({ ...f, [key]: value }))}
|
||||||
|
onFormPatch={patch => setForm(f => typeof patch === 'function' ? patch(f) : { ...f, ...patch })}
|
||||||
|
err={err}
|
||||||
|
plats={plats}
|
||||||
|
multiDetenteur={multiDetenteur}
|
||||||
|
investisseurs={investisseurs}
|
||||||
|
comptesInvestisseur={comptesInvestisseur}
|
||||||
|
onPlateformeChange={handlePlateformeChange}
|
||||||
|
categoriesInv={categoriesInv}
|
||||||
|
secteursInv={secteursInv}
|
||||||
|
onCategoryAdded={handleCategoryAdded}
|
||||||
|
onSectorAdded={handleSectorAdded}
|
||||||
width={720}
|
width={720}
|
||||||
footer={
|
/>
|
||||||
<>
|
|
||||||
<button onClick={close}>Annuler</button>
|
|
||||||
<button className="primary" onClick={submit}>{editingId ? 'Enregistrer' : 'Créer'}</button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<form onSubmit={submit}>
|
|
||||||
{err && <div className="error">{err}</div>}
|
|
||||||
<div className="form-grid">
|
|
||||||
<div>
|
|
||||||
<label>Plateforme *</label>
|
|
||||||
<select required value={form.plateforme_id} onChange={e => {
|
|
||||||
const platId = e.target.value;
|
|
||||||
const platObj = plats.find(p => String(p.id) === String(platId));
|
|
||||||
const platMethode = platObj?.methode_remboursement;
|
|
||||||
const newMethode = platMethode === 'choix_investisseur' ? '' : (platMethode || '');
|
|
||||||
// Pré-remplir catégories/secteurs depuis la plateforme
|
|
||||||
if (platId) {
|
|
||||||
Promise.all([
|
|
||||||
api.get(`/plateformes/${platId}/categories-inv`).catch(() => []),
|
|
||||||
api.get(`/plateformes/${platId}/secteurs-inv`).catch(() => []),
|
|
||||||
]).then(([platCats, platSects]) => {
|
|
||||||
setForm(f => ({ ...f,
|
|
||||||
plateforme_id: platId,
|
|
||||||
investisseur_id: investisseurForPlat(platId),
|
|
||||||
methode_remboursement: newMethode, compte_id: '',
|
|
||||||
categories_inv_ids: platCats.map(c => c.id),
|
|
||||||
secteurs_inv_ids: platSects.map(s => s.id),
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setForm(f => ({ ...f, plateforme_id: platId, investisseur_id: investisseurForPlat(platId), methode_remboursement: newMethode, compte_id: '', categories_inv_ids: [], secteurs_inv_ids: [] }));
|
|
||||||
}
|
|
||||||
}}>
|
|
||||||
<option value="">—</option>
|
|
||||||
{plats.map(p => <option key={p.id} value={p.id}>{p.nom}{multiDetenteur && p.investisseur_nom ? ` — ${p.investisseur_nom}` : ''}</option>)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
{(() => {
|
|
||||||
const platMethode = plats.find(p => String(p.id) === String(form.plateforme_id))?.methode_remboursement;
|
|
||||||
if (!platMethode) return null;
|
|
||||||
if (platMethode === 'choix_investisseur') return (
|
|
||||||
<div>
|
|
||||||
<label>Méthode de remboursement</label>
|
|
||||||
<select value={form.methode_remboursement} onChange={e => {
|
|
||||||
const methode = e.target.value;
|
|
||||||
const defaultCompte = methode === 'compte_courant'
|
|
||||||
? (comptesInvestisseur.find(c => c.type === 'compte_courant') ?? comptesInvestisseur[0])
|
|
||||||
: null;
|
|
||||||
setForm({ ...form, methode_remboursement: methode, nom_compte_courant: defaultCompte?.nom || '', compte_id: String(defaultCompte?.id || '') });
|
|
||||||
}}>
|
|
||||||
<option value="">— Non renseignée —</option>
|
|
||||||
<option value="portefeuille">Porte-monnaie de la plateforme</option>
|
|
||||||
<option value="compte_courant">Compte courant de l'investisseur</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
// Méthode fixée par la plateforme — lecture seule
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<label>Méthode de remboursement</label>
|
|
||||||
<input readOnly value={platMethode === 'compte_courant' ? "Compte courant de l'investisseur" : 'Porte-monnaie de la plateforme'}
|
|
||||||
style={{ background: 'var(--surface-2)', color: 'var(--text-muted)', cursor: 'default' }} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
{form.methode_remboursement === 'compte_courant' && (
|
|
||||||
<div>
|
|
||||||
<label>Compte de réception *</label>
|
|
||||||
{comptesInvestisseur.length > 0 ? (
|
|
||||||
<select
|
|
||||||
required
|
|
||||||
value={form.compte_id}
|
|
||||||
onChange={e => {
|
|
||||||
const c = comptesInvestisseur.find(c => String(c.id) === e.target.value);
|
|
||||||
setForm({ ...form, compte_id: e.target.value, nom_compte_courant: c?.nom || '' });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="">— Choisir un compte —</option>
|
|
||||||
{comptesInvestisseur.map(c => (
|
|
||||||
<option key={c.id} value={c.id}>{c.nom}{c.banque ? ` — ${c.banque}` : ''}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
) : (
|
|
||||||
<p className="text-muted" style={{ fontSize: 'var(--fs-sm)', margin: '4px 0' }}>
|
|
||||||
Aucun compte défini pour ce détenteur. <a href="/settings?section=comptes" target="_blank" rel="noreferrer">Créer un compte →</a>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{/* Détenteur auto-déduit de la plateforme — champ masqué */}
|
|
||||||
<input type="hidden" value={form.investisseur_id} />
|
|
||||||
|
|
||||||
<div style={{ gridColumn: 'span 2' }}>
|
|
||||||
<label>Nom du projet *</label>
|
|
||||||
<input required value={form.nom_projet} onChange={e => setForm({ ...form, nom_projet: e.target.value })} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label>Montant investi (€) *</label>
|
|
||||||
<input type="number" step="0.01" min="0" required value={form.montant_investi}
|
|
||||||
onChange={e => setForm({ ...form, montant_investi: e.target.value })} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label>Taux annuel (%) *</label>
|
|
||||||
<input type="number" step="0.01" required value={form.taux_interet}
|
|
||||||
onChange={e => setForm({ ...form, taux_interet: e.target.value })} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label>Durée (mois) *</label>
|
|
||||||
<input type="number" min="0" required value={form.duree_mois} onChange={e => {
|
|
||||||
const duree = e.target.value;
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setForm(next);
|
|
||||||
}} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label>Type de prêt</label>
|
|
||||||
<select value={form.type_remb} onChange={e => {
|
|
||||||
const t = e.target.value;
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setForm(next);
|
|
||||||
}}>
|
|
||||||
<option value="in_fine">Prêt in fine</option>
|
|
||||||
<option value="amortissable">Prêt amortissable</option>
|
|
||||||
<option value="differe">Prêt différé</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label>Fréquence intérêts</label>
|
|
||||||
<select
|
|
||||||
value={form.type_remb === 'differe' ? 'in_fine' : form.freq_interets}
|
|
||||||
disabled={form.type_remb === 'differe'}
|
|
||||||
onChange={e => setForm({ ...form, freq_interets: e.target.value })}
|
|
||||||
>
|
|
||||||
{form.type_remb === 'differe' ? (
|
|
||||||
<option value="in_fine">In fine (unique)</option>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<option value="mensuel">Mensuel</option>
|
|
||||||
<option value="trimestriel">Trimestriel</option>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label>Date souscription *</label>
|
|
||||||
<input type="date" required value={form.date_souscription} onChange={e => {
|
|
||||||
const souscription = e.target.value;
|
|
||||||
setForm(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 {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label>{form.type_remb === 'differe' ? "Date d'échéance (versement unique) *" : 'Date 1ère échéance *'}</label>
|
|
||||||
<input type="date" required value={form.date_premiere_echeance}
|
|
||||||
onChange={e => {
|
|
||||||
const dpe = e.target.value;
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
setForm(next);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{/* ── Case "Dernier jour du mois" — visible si jour > 27 ── */}
|
|
||||||
{(form.type_remb === 'in_fine' || form.type_remb === 'differe') &&
|
|
||||||
dayOfMonth(form.date_premiere_echeance) > 27 && (
|
|
||||||
<label style={{
|
|
||||||
display: 'inline-flex', alignItems: 'center', gap: 8,
|
|
||||||
marginTop: 6, cursor: 'pointer', userSelect: 'none',
|
|
||||||
fontSize: 'var(--fs-sm)', color: 'var(--text)',
|
|
||||||
width: 'fit-content',
|
|
||||||
}}>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
style={{ width: 'auto', flexShrink: 0 }}
|
|
||||||
checked={form.echeance_fin_de_mois}
|
|
||||||
onChange={e => {
|
|
||||||
const checked = e.target.checked;
|
|
||||||
setForm(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;
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
Dernier jour du mois
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label>Date cible contractuelle{form.type_remb === 'differe' ? ' — calculée' : ''}</label>
|
|
||||||
<input type="date" value={form.date_cible}
|
|
||||||
readOnly={form.type_remb === 'differe'}
|
|
||||||
onChange={e => setForm({ ...form, date_cible: e.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label>Pays d'exposition</label>
|
|
||||||
<CountrySelect
|
|
||||||
value={form.pays_exposition || 'FR'}
|
|
||||||
onChange={code => setForm(f => ({ ...f, pays_exposition: code }))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label>Émetteur</label>
|
|
||||||
<input value={form.emetteur}
|
|
||||||
onChange={e => setForm({ ...form, emetteur: e.target.value })}
|
|
||||||
placeholder="Nom de l'emprunteur"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{editingId && (
|
|
||||||
<div>
|
|
||||||
<label>Statut</label>
|
|
||||||
<select value={form.statut} onChange={e => setForm({ ...form, statut: e.target.value })}>
|
|
||||||
<option value="en_cours">En cours</option>
|
|
||||||
<option value="rembourse">Remboursé</option>
|
|
||||||
<option value="en_retard">En retard</option>
|
|
||||||
<option value="procedure">Procédure</option>
|
|
||||||
<option value="cloture">Clôturé</option>
|
|
||||||
<option value="perte_definitive">Perte définitive</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div>
|
|
||||||
<label>Référence</label>
|
|
||||||
<input value={form.reference}
|
|
||||||
onChange={e => setForm({ ...form, reference: e.target.value })}
|
|
||||||
placeholder="N° de dossier, code projet…"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div style={{ gridColumn: 'span 2' }}>
|
|
||||||
<label>Notes</label>
|
|
||||||
<textarea rows={3} value={form.notes}
|
|
||||||
onChange={e => setForm({ ...form, notes: e.target.value })}
|
|
||||||
placeholder="Informations complémentaires…"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Catégories d'investissement */}
|
|
||||||
<div style={{ gridColumn: 'span 3' }}>
|
|
||||||
<label>Catégories d'investissement</label>
|
|
||||||
<InvSelect
|
|
||||||
items={categoriesInv}
|
|
||||||
selected={form.categories_inv_ids || []}
|
|
||||||
onChange={ids => setForm(f => ({ ...f, categories_inv_ids: ids }))}
|
|
||||||
addApiPath="/categories-inv"
|
|
||||||
onItemAdded={cat => setCategoriesInv(prev => [...prev, cat].sort((a,b) => a.nom.localeCompare(b.nom)))}
|
|
||||||
emptyLabel="Aucune catégorie d'investissement"
|
|
||||||
addLabel="Ajouter une catégorie d'investissement"
|
|
||||||
inputPlaceholder="Nom de la catégorie…"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Secteurs d'investissement */}
|
|
||||||
<div style={{ gridColumn: 'span 3' }}>
|
|
||||||
<label>Secteurs d'investissement</label>
|
|
||||||
<InvSelect
|
|
||||||
items={secteursInv}
|
|
||||||
selected={form.secteurs_inv_ids || []}
|
|
||||||
onChange={ids => setForm(f => ({ ...f, secteurs_inv_ids: ids }))}
|
|
||||||
addApiPath="/secteurs-inv"
|
|
||||||
onItemAdded={sect => setSecteursInv(prev => [...prev, sect].sort((a,b) => a.nom.localeCompare(b.nom)))}
|
|
||||||
emptyLabel="Aucun secteur d'investissement"
|
|
||||||
addLabel="Ajouter un secteur d'investissement"
|
|
||||||
inputPlaceholder="Nom du secteur…"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
{openMenu && (
|
{openMenu && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* Helpers de calcul de dates pour les formulaires investissement
|
||||||
|
* (échéances, durée, fin de mois). Utilisés par InvestissementFormModal
|
||||||
|
* ainsi que par les pages Investissements / InvestissementDetail (ex. modale
|
||||||
|
* de révision des conditions).
|
||||||
|
*
|
||||||
|
* Extrait de code auparavant dupliqué à l'identique dans Investissements.jsx
|
||||||
|
* et InvestissementDetail.jsx.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Ajoute n mois (calendaires, JS Date) à une date ISO. */
|
||||||
|
export function addMonthsFE(isoDate, n) {
|
||||||
|
if (!isoDate || !n) return '';
|
||||||
|
const [y, m, d] = isoDate.split('-').map(Number);
|
||||||
|
const dt = new Date(Date.UTC(y, m - 1 + n, d));
|
||||||
|
return dt.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Retourne le dernier jour du mois de isoDate. Ex : '2024-01-28' → '2024-01-31' */
|
||||||
|
export function lastDayOfMonthFE(isoDate) {
|
||||||
|
if (!isoDate) return isoDate;
|
||||||
|
const [y, m] = isoDate.split('-').map(Number);
|
||||||
|
const dt = new Date(Date.UTC(y, m, 0)); // jour 0 du mois suivant
|
||||||
|
return dt.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ajoute n mois et positionne sur le dernier jour du mois cible.
|
||||||
|
* Évite les débordements JS (31 jan + 1 = 3 mar → ici = 29 fév).
|
||||||
|
*/
|
||||||
|
export function addMonthsFinDeMoisFE(isoDate, n) {
|
||||||
|
if (!isoDate || (n === undefined || n === null)) return '';
|
||||||
|
const [y, m] = isoDate.split('-').map(Number);
|
||||||
|
const dt = new Date(Date.UTC(y, m - 1 + Number(n) + 1, 0));
|
||||||
|
return dt.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Retourne le numéro du jour dans le mois (1-31) ou 0 si absent. */
|
||||||
|
export function dayOfMonth(isoDate) {
|
||||||
|
if (!isoDate || isoDate.length < 10) return 0;
|
||||||
|
return parseInt(isoDate.slice(8, 10), 10);
|
||||||
|
}
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
// vite.config.js
|
|
||||||
import { defineConfig } from "file:///sessions/rcw-01pwh3iqkqa3ylxs4htcpsue/mnt/C:--dev--crowdlending-app/frontend/node_modules/vite/dist/node/index.js";
|
|
||||||
import react from "file:///sessions/rcw-01pwh3iqkqa3ylxs4htcpsue/mnt/C:--dev--crowdlending-app/frontend/node_modules/@vitejs/plugin-react/dist/index.js";
|
|
||||||
var vite_config_default = defineConfig({
|
|
||||||
plugins: [react()],
|
|
||||||
server: {
|
|
||||||
port: 5173,
|
|
||||||
proxy: {
|
|
||||||
"/api": {
|
|
||||||
target: "http://localhost:4000",
|
|
||||||
changeOrigin: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
build: {
|
|
||||||
outDir: "dist"
|
|
||||||
}
|
|
||||||
});
|
|
||||||
export {
|
|
||||||
vite_config_default as default
|
|
||||||
};
|
|
||||||
//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsidml0ZS5jb25maWcuanMiXSwKICAic291cmNlc0NvbnRlbnQiOiBbImNvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9kaXJuYW1lID0gXCIvc2Vzc2lvbnMvcmN3LTAxcHdoM2lxa3FhM3lseHM0aHRjcHN1ZS9tbnQvQzotLWRldi0tY3Jvd2RsZW5kaW5nLWFwcC9mcm9udGVuZFwiO2NvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9maWxlbmFtZSA9IFwiL3Nlc3Npb25zL3Jjdy0wMXB3aDNpcWtxYTN5bHhzNGh0Y3BzdWUvbW50L0M6LS1kZXYtLWNyb3dkbGVuZGluZy1hcHAvZnJvbnRlbmQvdml0ZS5jb25maWcuanNcIjtjb25zdCBfX3ZpdGVfaW5qZWN0ZWRfb3JpZ2luYWxfaW1wb3J0X21ldGFfdXJsID0gXCJmaWxlOi8vL3Nlc3Npb25zL3Jjdy0wMXB3aDNpcWtxYTN5bHhzNGh0Y3BzdWUvbW50L0M6LS1kZXYtLWNyb3dkbGVuZGluZy1hcHAvZnJvbnRlbmQvdml0ZS5jb25maWcuanNcIjtpbXBvcnQgeyBkZWZpbmVDb25maWcgfSBmcm9tICd2aXRlJztcclxuaW1wb3J0IHJlYWN0IGZyb20gJ0B2aXRlanMvcGx1Z2luLXJlYWN0JztcclxuXHJcbmV4cG9ydCBkZWZhdWx0IGRlZmluZUNvbmZpZyh7XHJcbiAgcGx1Z2luczogW3JlYWN0KCldLFxyXG4gIHNlcnZlcjoge1xyXG4gICAgcG9ydDogNTE3MyxcclxuICAgIHByb3h5OiB7XHJcbiAgICAgICcvYXBpJzoge1xyXG4gICAgICAgIHRhcmdldDogJ2h0dHA6Ly9sb2NhbGhvc3Q6NDAwMCcsXHJcbiAgICAgICAgY2hhbmdlT3JpZ2luOiB0cnVlLFxyXG4gICAgICB9LFxyXG4gICAgfSxcclxuICB9LFxyXG4gIGJ1aWxkOiB7XHJcbiAgICBvdXREaXI6ICdkaXN0JyxcclxuICB9LFxyXG59KTtcclxuIl0sCiAgIm1hcHBpbmdzIjogIjtBQUF5WixTQUFTLG9CQUFvQjtBQUN0YixPQUFPLFdBQVc7QUFFbEIsSUFBTyxzQkFBUSxhQUFhO0FBQUEsRUFDMUIsU0FBUyxDQUFDLE1BQU0sQ0FBQztBQUFBLEVBQ2pCLFFBQVE7QUFBQSxJQUNOLE1BQU07QUFBQSxJQUNOLE9BQU87QUFBQSxNQUNMLFFBQVE7QUFBQSxRQUNOLFFBQVE7QUFBQSxRQUNSLGNBQWM7QUFBQSxNQUNoQjtBQUFBLElBQ0Y7QUFBQSxFQUNGO0FBQUEsRUFDQSxPQUFPO0FBQUEsSUFDTCxRQUFRO0FBQUEsRUFDVjtBQUNGLENBQUM7IiwKICAibmFtZXMiOiBbXQp9Cg==
|
|
||||||
Reference in new issue
Block a user