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
@@ -4,12 +4,12 @@ import Pagination from '../components/Pagination.jsx';
|
||||
import PageIcon from '../components/PageIcon.jsx';
|
||||
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { api } from '../api.js';
|
||||
import InvSelect from '../components/InvSelect.jsx';
|
||||
import InvestissementFormModal from '../components/InvestissementFormModal.jsx';
|
||||
import { useInvestisseur } from '../context/InvestisseurContext.jsx';
|
||||
import { useUi } from '../context/UiContext.jsx';
|
||||
import Modal from '../components/Modal.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 DistributionChart from '../components/DistributionChart.jsx';
|
||||
import CapitalMensuelTable from '../components/CapitalMensuelTable.jsx';
|
||||
@@ -241,38 +241,6 @@ const empty = {
|
||||
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 ─────────────────────────────────────── */
|
||||
export default function Investissements() {
|
||||
const { activeId, activeView, investisseurs } = useInvestisseur();
|
||||
@@ -700,6 +668,33 @@ export default function Investissements() {
|
||||
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 submit = async (e) => {
|
||||
@@ -1282,347 +1277,26 @@ export default function Investissements() {
|
||||
)}
|
||||
|
||||
{/* ── Modal création / édition ── */}
|
||||
<Modal
|
||||
<InvestissementFormModal
|
||||
open={modalOpen}
|
||||
title={editingId ? 'Modifier l\'investissement' : 'Nouvel investissement'}
|
||||
editingId={editingId}
|
||||
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}
|
||||
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 && (
|
||||
<>
|
||||
|
||||
Reference in new issue
Block a user