Amélioration de la modale Investiisement

This commit is contained in:
ocroguennec committed 2026-08-29 15:14:02 +02:00
1 parent 44b8e5eda0
commit c7bbe5dee6
6 files changed
+698 -810

No files matched your search

+46 -372
View File
@@ -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 && (
<>