Implémentation de la feature "suivi des objectifs"
This commit is contained in:
1 parent
6e03195d3a
commit
1060bfe2be
14 files changed
+1844
-192
No files matched your search
@@ -0,0 +1,60 @@
|
||||
import { fmtEUR } from '../utils/format.js';
|
||||
|
||||
/*
|
||||
* Petits composants de présentation partagés par les tableaux de suivi
|
||||
* d'objectifs (settings/ObjectifsSection.jsx, components/SuiviObjectifs.jsx,
|
||||
* components/SuiviObjectifsCapital.jsx) : phrase d'écart colorée et jauge
|
||||
* de taux d'atteinte. Centralisés ici pour éviter la duplication entre
|
||||
* la page de paramétrage (éditable) et les vues de suivi (lecture seule).
|
||||
*/
|
||||
|
||||
// overColor : couleur utilisée quand l'écart est positif (objectif dépassé).
|
||||
// Vert par défaut (ex. capital investi : dépasser l'objectif est positif).
|
||||
// Pour les écarts de versement, l'appelant passe 'var(--danger)' — dépasser
|
||||
// le montant de versement complémentaire prévu appelle une vigilance, pas
|
||||
// une célébration.
|
||||
export function EcartCell({ ecart, overColor = 'var(--success)' }) {
|
||||
if (ecart == null) return <span style={{ color: 'var(--text-muted)' }}>—</span>;
|
||||
// Écart nul (aux arrondis flottants près) : un seul et même libellé, qu'il
|
||||
// soit arrivé côté positif ou négatif du zéro.
|
||||
if (Math.abs(ecart) < 0.01) return <span style={{ color: 'var(--success)', fontWeight: 600 }}>Conforme à l'objectif</span>;
|
||||
return ecart >= 0
|
||||
? <span style={{ color: overColor, fontWeight: 600 }}>+{fmtEUR(ecart)} au-delà de l'objectif</span>
|
||||
: <span style={{ color: 'var(--warning)', fontWeight: 600 }}>{fmtEUR(-ecart)} en dessous de l'objectif</span>;
|
||||
}
|
||||
|
||||
// Dégradé gris (0 %) → orange (50 %) → vert (100 % et au-delà).
|
||||
function pctColor(pct) {
|
||||
const p = Math.max(0, Math.min(100, pct));
|
||||
const gray = [156, 163, 175];
|
||||
const orange = [245, 158, 11];
|
||||
const green = [34, 197, 94];
|
||||
const [c1, c2, t] = p <= 50 ? [gray, orange, p / 50] : [orange, green, (p - 50) / 50];
|
||||
const mix = i => Math.round(c1[i] + (c2[i] - c1[i]) * t);
|
||||
return `rgb(${mix(0)}, ${mix(1)}, ${mix(2)})`;
|
||||
}
|
||||
|
||||
// overColor : couleur utilisée quand le taux dépasse 100 % (au lieu de rester
|
||||
// au vert du dégradé). Pour les taux de versement, l'appelant passe
|
||||
// 'var(--danger)' — cf. EcartCell, même logique de vigilance en cas de
|
||||
// dépassement du montant de versement complémentaire prévu.
|
||||
export function TauxAtteinte({ realise, objectif, overColor = null }) {
|
||||
if (objectif == null || !(objectif > 0)) return <span style={{ color: 'var(--text-muted)' }}>—</span>;
|
||||
const pct = (realise / objectif) * 100;
|
||||
// Dépassement de plus d'un centime seulement — même tolérance que "Conforme
|
||||
// à l'objectif" dans EcartCell, pour éviter qu'un écart d'arrondi flottant
|
||||
// fasse basculer en rouge un taux réellement à 100 %.
|
||||
const isOver = (realise - objectif) > 0.01;
|
||||
const color = isOver && overColor ? overColor : pctColor(pct);
|
||||
const barPct = Math.max(0, Math.min(100, pct));
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 100 }}>
|
||||
<div style={{ flex: 1, height: 6, borderRadius: 3, background: 'var(--surface-2)', overflow: 'hidden' }}>
|
||||
<div style={{ width: `${barPct}%`, height: '100%', background: color, borderRadius: 3 }} />
|
||||
</div>
|
||||
<span style={{ color, fontWeight: 700, fontSize: 'var(--fs-xs)', minWidth: 38, textAlign: 'right' }}>
|
||||
{Math.round(pct)} %
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,23 +1,26 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { api } from '../api.js';
|
||||
import { fmtEUR, memberLabel } from '../utils/format.js';
|
||||
import { fmtEUR, fmtRetrait } from '../utils/format.js';
|
||||
import { EcartCell, TauxAtteinte } from './ObjectifIndicators.jsx';
|
||||
|
||||
/*
|
||||
* SuiviObjectifs — tableau générique de suivi d'objectifs annuels.
|
||||
* SuiviObjectifs — tableau générique de suivi d'objectifs annuels (lecture seule).
|
||||
*
|
||||
* Conçu pour être réutilisable au-delà des Dépôts/Retraits : il suffit de
|
||||
* fournir des lignes de mouvements bruts (avec investisseur_id, type, montant,
|
||||
* date) et la liste des investisseurs concernés. La table `objectifs`
|
||||
* (backend) porte un champ `type` qui permet de stocker d'autres natures
|
||||
* d'objectifs plus tard sans nouvelle table.
|
||||
* Depuis la mise en place de la section Paramétrage > Fixation des objectifs,
|
||||
* ce composant n'est plus éditable : les objectifs se définissent exclusivement
|
||||
* dans les Paramétrages (cf. settings/ObjectifsSection.jsx), qui montre la
|
||||
* relation entre l'enveloppe globale de versement et son allocation par
|
||||
* plateforme. Ici, on affiche uniquement l'écart entre le réalisé et l'objectif
|
||||
* déjà fixé, avec un lien vers la page de paramétrage pour le modifier.
|
||||
*
|
||||
* Props :
|
||||
* - rows : mouvements bruts [{ investisseur_id, type: 'depot'|'retrait', montant, date_operation }]
|
||||
* - investisseurs : liste complète des investisseurs (pour affichage des noms)
|
||||
* - scopeInvestisseurIds : ids des investisseurs actuellement dans le scope de vue
|
||||
* (tous les investisseurs si vue "tous", sinon [activeId])
|
||||
* - objectifType : clé de type stockée en base (défaut 'versement_annuel')
|
||||
* - title : titre de la section
|
||||
* - rows : mouvements bruts [{ investisseur_id, type: 'depot'|'retrait', montant, date_operation }]
|
||||
* - investisseurs : liste complète des investisseurs (pour affichage des noms)
|
||||
* - scopeInvestisseurIds : ids des investisseurs actuellement dans le scope de vue
|
||||
* (tous les investisseurs si vue "tous", sinon [activeId])
|
||||
* - objectifType : clé de type stockée en base (défaut 'versement_annuel')
|
||||
* - title : titre de la section
|
||||
*/
|
||||
export default function SuiviObjectifs({
|
||||
rows = [],
|
||||
@@ -26,31 +29,21 @@ export default function SuiviObjectifs({
|
||||
objectifType = 'versement_annuel',
|
||||
title = 'Suivi des objectifs de versement',
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const [objectifs, setObjectifs] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [editingYear, setEditingYear] = useState(null);
|
||||
const [draft, setDraft] = useState({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [extraYears, setExtraYears] = useState([]);
|
||||
|
||||
const loadObjectifs = () => {
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
api.get('/objectifs', { type: objectifType })
|
||||
.then(rows => setObjectifs(Array.isArray(rows) ? rows : []))
|
||||
.catch(() => setObjectifs([]))
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => { loadObjectifs(); /* eslint-disable-next-line */ }, [objectifType]);
|
||||
|
||||
const scopedInvestisseurs = useMemo(
|
||||
() => investisseurs.filter(i => scopeInvestisseurIds.includes(i.id)),
|
||||
[investisseurs, scopeInvestisseurIds]
|
||||
);
|
||||
}, [objectifType]);
|
||||
|
||||
/* ── Années à afficher : celles avec mouvements + celles avec objectif + année courante ── */
|
||||
const years = useMemo(() => {
|
||||
const set = new Set(extraYears);
|
||||
const set = new Set();
|
||||
for (const r of rows) {
|
||||
if (scopeInvestisseurIds.includes(r.investisseur_id) && r.date_operation) {
|
||||
set.add(Number(r.date_operation.slice(0, 4)));
|
||||
@@ -61,7 +54,7 @@ export default function SuiviObjectifs({
|
||||
}
|
||||
set.add(new Date().getFullYear());
|
||||
return [...set].sort((a, b) => b - a);
|
||||
}, [rows, objectifs, scopeInvestisseurIds, extraYears]);
|
||||
}, [rows, objectifs, scopeInvestisseurIds]);
|
||||
|
||||
/* ── Totaux dépôts/retraits par année (scope courant) ── */
|
||||
const totalsByYear = useMemo(() => {
|
||||
@@ -88,53 +81,17 @@ export default function SuiviObjectifs({
|
||||
return map;
|
||||
}, [objectifs, scopeInvestisseurIds]);
|
||||
|
||||
const openEditor = (year) => {
|
||||
const existing = objectifsByYear[year] || [];
|
||||
const d = {};
|
||||
for (const inv of scopedInvestisseurs) {
|
||||
const found = existing.find(o => o.investisseur_id === inv.id);
|
||||
d[inv.id] = found ? String(found.montant) : '';
|
||||
}
|
||||
setDraft(d);
|
||||
setEditingYear(year);
|
||||
};
|
||||
|
||||
const cancelEditor = () => { setEditingYear(null); setDraft({}); };
|
||||
|
||||
const saveEditor = async (year) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const existing = objectifsByYear[year] || [];
|
||||
const calls = [];
|
||||
for (const inv of scopedInvestisseurs) {
|
||||
const raw = draft[inv.id];
|
||||
const found = existing.find(o => o.investisseur_id === inv.id);
|
||||
if (raw === '' || raw === undefined) {
|
||||
if (found) calls.push(api.del(`/objectifs/${found.id}`));
|
||||
continue;
|
||||
}
|
||||
const montant = Number(raw);
|
||||
if (Number.isNaN(montant) || montant < 0) continue;
|
||||
calls.push(api.post('/objectifs', {
|
||||
investisseur_id: inv.id, type: objectifType, annee: year, montant,
|
||||
}));
|
||||
}
|
||||
await Promise.all(calls);
|
||||
loadObjectifs();
|
||||
cancelEditor();
|
||||
} finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const addYear = () => {
|
||||
const maxYear = years.length ? Math.max(...years) : new Date().getFullYear();
|
||||
setExtraYears(prev => [...prev, maxYear + 1]);
|
||||
};
|
||||
|
||||
/* ── Ligne Total : somme des colonnes sur les années affichées ── */
|
||||
/* ── Ligne Total : somme des colonnes sur les années affichées ──
|
||||
`diffAvecObjectif` ne cumule le versement net que sur les années où un
|
||||
objectif est fixé — comparer le taux d'atteinte / l'écart à `diff` (qui
|
||||
inclut aussi les années "Non défini") gonflerait artificiellement le
|
||||
réalisé par rapport à l'objectif et ferait passer l'indicateur au rouge
|
||||
à tort alors que chaque année avec objectif y est individuellement
|
||||
conforme. */
|
||||
const overallTotals = useMemo(() => {
|
||||
let depots = 0, retraits = 0;
|
||||
let objectifSum = 0, hasObjectif = false;
|
||||
let ecartSum = 0, hasEcart = false;
|
||||
let diffAvecObjectif = 0, ecartSum = 0, hasEcart = false;
|
||||
for (const year of years) {
|
||||
const t = totalsByYear[year] || { depots: 0, retraits: 0 };
|
||||
depots += t.depots;
|
||||
@@ -144,12 +101,14 @@ export default function SuiviObjectifs({
|
||||
const objectifTotal = objs.reduce((s, o) => s + o.montant, 0);
|
||||
objectifSum += objectifTotal;
|
||||
hasObjectif = true;
|
||||
diffAvecObjectif += t.depots - t.retraits;
|
||||
ecartSum += (t.depots - t.retraits) - objectifTotal;
|
||||
hasEcart = true;
|
||||
}
|
||||
}
|
||||
return {
|
||||
depots, retraits, diff: depots - retraits,
|
||||
diffAvecObjectif: hasObjectif ? diffAvecObjectif : null,
|
||||
objectifTotal: hasObjectif ? objectifSum : null,
|
||||
ecart: hasEcart ? ecartSum : null,
|
||||
};
|
||||
@@ -159,8 +118,13 @@ export default function SuiviObjectifs({
|
||||
<div className="card" style={{ marginTop: 20 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
|
||||
<h3 style={{ margin: 0 }}>{title}</h3>
|
||||
<button type="button" className="btn-outline" onClick={addYear} style={{ fontSize: 'var(--fs-sm)' }}>
|
||||
+ Ajouter une année
|
||||
<button
|
||||
type="button"
|
||||
className="btn-outline"
|
||||
onClick={() => navigate('/settings?section=objectifs')}
|
||||
style={{ fontSize: 'var(--fs-sm)' }}
|
||||
>
|
||||
Définir dans Paramétrage →
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -170,9 +134,10 @@ export default function SuiviObjectifs({
|
||||
<th style={{ padding: '8px 10px' }}>Année</th>
|
||||
<th style={{ padding: '8px 10px' }}>Dépôts</th>
|
||||
<th style={{ padding: '8px 10px' }}>Retraits</th>
|
||||
<th style={{ padding: '8px 10px' }}>Différence</th>
|
||||
<th style={{ padding: '8px 10px' }}>Objectif annuel</th>
|
||||
<th style={{ padding: '8px 10px' }}>Écart</th>
|
||||
<th style={{ padding: '8px 10px' }}>Versement net</th>
|
||||
<th style={{ padding: '8px 10px' }}>Objectifs (Versement)</th>
|
||||
<th style={{ padding: '8px 10px' }}>Atteinte</th>
|
||||
<th style={{ padding: '8px 10px' }}>Écart de versement</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -182,69 +147,18 @@ export default function SuiviObjectifs({
|
||||
const objs = objectifsByYear[year] || [];
|
||||
const objectifTotal = objs.length > 0 ? objs.reduce((s, o) => s + o.montant, 0) : null;
|
||||
const ecart = objectifTotal != null ? diff - objectifTotal : null;
|
||||
const isEditing = editingYear === year;
|
||||
|
||||
return (
|
||||
<tr key={year} style={{ borderTop: '1px solid var(--border)' }}>
|
||||
<td style={{ padding: '8px 10px', fontWeight: 600 }}>{year}</td>
|
||||
<td style={{ padding: '8px 10px', color: 'var(--success)' }}>{fmtEUR(t.depots)}</td>
|
||||
<td style={{ padding: '8px 10px', color: 'var(--danger)' }}>{fmtEUR(t.retraits)}</td>
|
||||
<td style={{ padding: '8px 10px', fontWeight: 600 }}>{fmtEUR(diff)}</td>
|
||||
<td style={{ padding: '8px 10px' }}>
|
||||
{isEditing ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{scopedInvestisseurs.map(inv => (
|
||||
<div key={inv.id} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
{scopedInvestisseurs.length > 1 && (
|
||||
<span style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', minWidth: 90 }}>
|
||||
{memberLabel(inv)}
|
||||
</span>
|
||||
)}
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
placeholder="—"
|
||||
value={draft[inv.id] ?? ''}
|
||||
onChange={e => setDraft(d => ({ ...d, [inv.id]: e.target.value }))}
|
||||
style={{ width: 110, padding: '4px 6px', border: '1px solid var(--border)', borderRadius: 6, background: 'var(--surface)' }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<div style={{ display: 'flex', gap: 6, marginTop: 2 }}>
|
||||
<button type="button" disabled={saving} onClick={() => saveEditor(year)} style={{ fontSize: 'var(--fs-xs)' }}>
|
||||
Enregistrer
|
||||
</button>
|
||||
<button type="button" className="ghost" disabled={saving} onClick={cancelEditor} style={{ fontSize: 'var(--fs-xs)' }}>
|
||||
Annuler
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="ghost"
|
||||
onClick={() => openEditor(year)}
|
||||
style={{ padding: '2px 6px', fontSize: 'var(--fs-sm)', color: objectifTotal != null ? 'var(--text)' : 'var(--text-muted)' }}
|
||||
title="Cliquer pour définir l'objectif"
|
||||
>
|
||||
{objectifTotal != null ? fmtEUR(objectifTotal) : '+ Définir'}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ padding: '8px 10px' }}>
|
||||
{ecart == null ? (
|
||||
<span style={{ color: 'var(--text-muted)' }}>—</span>
|
||||
) : ecart >= 0 ? (
|
||||
<span style={{ color: 'var(--success)', fontWeight: 600 }}>
|
||||
+{fmtEUR(ecart)} au-delà de l'objectif
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ color: 'var(--warning)', fontWeight: 600 }}>
|
||||
Reste {fmtEUR(-ecart)}
|
||||
</span>
|
||||
)}
|
||||
<td style={{ padding: '8px 10px', color: 'var(--danger)' }}>{fmtRetrait(t.retraits)}</td>
|
||||
<td style={{ padding: '8px 10px', color: 'var(--text-muted)' }}>{fmtEUR(diff)}</td>
|
||||
<td style={{ padding: '8px 10px', color: objectifTotal != null ? 'var(--text)' : 'var(--text-muted)' }}>
|
||||
{objectifTotal != null ? fmtEUR(objectifTotal) : 'Non défini'}
|
||||
</td>
|
||||
<td style={{ padding: '8px 10px' }}><TauxAtteinte realise={diff} objectif={objectifTotal} overColor="var(--danger)" /></td>
|
||||
<td style={{ padding: '8px 10px' }}><EcartCell ecart={ecart} overColor="var(--danger)" /></td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
@@ -253,20 +167,13 @@ export default function SuiviObjectifs({
|
||||
<tr style={{ borderTop: '2px solid var(--border)' }}>
|
||||
<td style={{ padding: '8px 10px', fontWeight: 700 }}>Total</td>
|
||||
<td style={{ padding: '8px 10px', fontWeight: 700, color: 'var(--success)' }}>{fmtEUR(overallTotals.depots)}</td>
|
||||
<td style={{ padding: '8px 10px', fontWeight: 700, color: 'var(--danger)' }}>{fmtEUR(overallTotals.retraits)}</td>
|
||||
<td style={{ padding: '8px 10px', fontWeight: 700, color: 'var(--danger)' }}>{fmtRetrait(overallTotals.retraits)}</td>
|
||||
<td style={{ padding: '8px 10px', fontWeight: 700 }}>{fmtEUR(overallTotals.diff)}</td>
|
||||
<td style={{ padding: '8px 10px', fontWeight: 700 }}>
|
||||
{overallTotals.objectifTotal != null ? fmtEUR(overallTotals.objectifTotal) : <span style={{ color: 'var(--text-muted)' }}>—</span>}
|
||||
</td>
|
||||
<td style={{ padding: '8px 10px', fontWeight: 700 }}>
|
||||
{overallTotals.ecart == null ? (
|
||||
<span style={{ color: 'var(--text-muted)' }}>—</span>
|
||||
) : overallTotals.ecart >= 0 ? (
|
||||
<span style={{ color: 'var(--success)' }}>+{fmtEUR(overallTotals.ecart)} au-delà de l'objectif</span>
|
||||
) : (
|
||||
<span style={{ color: 'var(--warning)' }}>Reste {fmtEUR(-overallTotals.ecart)}</span>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ padding: '8px 10px', fontWeight: 700 }}><TauxAtteinte realise={overallTotals.diffAvecObjectif} objectif={overallTotals.objectifTotal} overColor="var(--danger)" /></td>
|
||||
<td style={{ padding: '8px 10px', fontWeight: 700 }}><EcartCell ecart={overallTotals.ecart} overColor="var(--danger)" /></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { api } from '../api.js';
|
||||
import { fmtEUR } from '../utils/format.js';
|
||||
import { capitalEncoursParInvestissement } from '../utils/capitalInvesti.js';
|
||||
import { EcartCell, TauxAtteinte } from './ObjectifIndicators.jsx';
|
||||
|
||||
/*
|
||||
* SuiviObjectifsCapital — pendant "capital investi" de SuiviObjectifs.jsx,
|
||||
* pour la page Plateformes. Lecture seule : les objectifs eux-mêmes se
|
||||
* définissent dans Paramétrage > Fixation des objectifs
|
||||
* (cf. settings/ObjectifsSection.jsx), qui permet de répartir l'enveloppe
|
||||
* globale de versement en objectifs de capital investi par plateforme.
|
||||
*
|
||||
* Une ligne par année (même présentation que le tableau "Objectifs de
|
||||
* capital investi" par plateforme de ObjectifsSection.jsx, mais avec une
|
||||
* colonne Année à la place de la colonne Plateforme puisqu'ici le scope
|
||||
* — une ou plusieurs plateformes — est fixe et c'est l'année qui varie).
|
||||
*
|
||||
* La colonne "Capital investi" combine le réalisé et, si pertinent, la
|
||||
* projection en une seule phrase :
|
||||
* - année révolue : montant réel au 31/12/{année}
|
||||
* - année en cours : montant réel à la date du jour, puis la projection au
|
||||
* 31/12 entre parenthèses (calculée à partir des
|
||||
* remboursements de capital prévus, non encore réalisés)
|
||||
* - année future : uniquement la projection au 31/12 (rien n'est encore réalisé)
|
||||
*
|
||||
* Props :
|
||||
* - investissements : investissements déjà filtrés sur le scope (ex. une plateforme
|
||||
* sélectionnée, ou toutes les plateformes des investisseurs visibles)
|
||||
* - remboursementsCapital : remboursements de type 'normal', non filtrés (rattachement par investissement_id)
|
||||
* - reinvestissements : non filtrés (rattachement par investissement_id)
|
||||
* - simulRembs : projections (simul_remboursements), non filtrées — alimente la projection
|
||||
* des années en cours/futures (filtrage interne sur le scope via les
|
||||
* plateforme_id présents dans `investissements`)
|
||||
* - scopeInvestisseurIds : ids des investisseurs du scope courant (utilisé quand aucune
|
||||
* plateforme précise n'est sélectionnée, pour retrouver les objectifs
|
||||
* des plateformes leur appartenant)
|
||||
* - plateformeIds : Set d'ids de plateformes précisément sélectionnées (optionnel) —
|
||||
* si fourni et non vide, les objectifs sont filtrés sur ces plateformes
|
||||
* plutôt que sur scopeInvestisseurIds
|
||||
* - title : titre de la section
|
||||
*/
|
||||
export default function SuiviObjectifsCapital({
|
||||
investissements = [],
|
||||
remboursementsCapital = [],
|
||||
reinvestissements = [],
|
||||
simulRembs = [],
|
||||
scopeInvestisseurIds = [],
|
||||
plateformeIds = null,
|
||||
title = 'Suivi de l\'objectif de capital investi',
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const [objectifs, setObjectifs] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
api.get('/objectifs', { type: 'capital_investi' })
|
||||
.then(rows => setObjectifs(Array.isArray(rows) ? rows : []))
|
||||
.catch(() => setObjectifs([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const scopeSet = useMemo(() => new Set(scopeInvestisseurIds), [scopeInvestisseurIds]);
|
||||
const hasPlatFilter = plateformeIds && plateformeIds.size > 0;
|
||||
|
||||
const objectifsInScope = useMemo(
|
||||
() => objectifs.filter(o => hasPlatFilter ? plateformeIds.has(o.plateforme_id) : scopeSet.has(o.investisseur_id)),
|
||||
[objectifs, hasPlatFilter, plateformeIds, scopeSet]
|
||||
);
|
||||
|
||||
const years = useMemo(() => {
|
||||
const set = new Set();
|
||||
for (const inv of investissements) {
|
||||
if (inv.date_souscription) set.add(Number(inv.date_souscription.slice(0, 4)));
|
||||
}
|
||||
for (const o of objectifsInScope) set.add(Number(o.annee));
|
||||
set.add(new Date().getFullYear());
|
||||
return [...set].sort((a, b) => b - a);
|
||||
}, [investissements, objectifsInScope]);
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
const today = new Date();
|
||||
const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
||||
const fmtDMY = (d) => `${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}/${d.getFullYear()}`;
|
||||
|
||||
// Capital investi en début d'année (01/01) — point de référence.
|
||||
const capitalDebutByYear = useMemo(() => {
|
||||
const map = {};
|
||||
for (const year of years) {
|
||||
const cutoff = `${year - 1}-12-31`;
|
||||
const parInv = capitalEncoursParInvestissement(investissements, remboursementsCapital, reinvestissements, cutoff);
|
||||
map[year] = Object.values(parInv).reduce((s, v) => s + v, 0);
|
||||
}
|
||||
return map;
|
||||
}, [investissements, remboursementsCapital, reinvestissements, years]);
|
||||
|
||||
// Capital investi réel : à la date du jour pour l'année en cours, au 31/12 pour une année révolue.
|
||||
// N'a de sens que pour les années <= currentYear (une année future n'a encore rien de réel).
|
||||
const capitalActuelByYear = useMemo(() => {
|
||||
const map = {};
|
||||
for (const year of years) {
|
||||
if (year > currentYear) continue;
|
||||
const cutoff = year === currentYear ? todayStr : `${year}-12-31`;
|
||||
const parInv = capitalEncoursParInvestissement(investissements, remboursementsCapital, reinvestissements, cutoff);
|
||||
map[year] = Object.values(parInv).reduce((s, v) => s + v, 0);
|
||||
}
|
||||
return map;
|
||||
}, [investissements, remboursementsCapital, reinvestissements, years, currentYear, todayStr]);
|
||||
|
||||
const scopedPlatIds = useMemo(() => new Set(investissements.map(inv => inv.plateforme_id)), [investissements]);
|
||||
|
||||
// Projection à une date donnée (année en cours ou future) : capital réel
|
||||
// d'aujourd'hui moins les remboursements de capital PRÉVUS (non encore
|
||||
// réalisés) entre aujourd'hui et cette date.
|
||||
const projectedCapitalAt = (cutoffDate) => {
|
||||
const capitalAujourdhui = capitalActuelByYear[currentYear] ?? 0;
|
||||
let futur = 0;
|
||||
for (const s of simulRembs) {
|
||||
const d = s.date_prevue?.slice(0, 10);
|
||||
if (!d || d <= todayStr || d > cutoffDate) continue;
|
||||
if (!scopedPlatIds.has(s.plateforme_id)) continue;
|
||||
futur += s.capital_prevu || 0;
|
||||
}
|
||||
return Math.max(0, capitalAujourdhui - futur);
|
||||
};
|
||||
|
||||
const objectifByYear = useMemo(() => {
|
||||
const map = {};
|
||||
for (const o of objectifsInScope) {
|
||||
map[o.annee] = (map[o.annee] || 0) + o.montant;
|
||||
}
|
||||
return map;
|
||||
}, [objectifsInScope]);
|
||||
|
||||
// Une année sans aucun capital investi (ni en début d'année, ni à date/projeté)
|
||||
// et sans objectif fixé (ou un objectif nul) n'apporte rien au suivi — elle est
|
||||
// masquée plutôt que d'afficher une ligne entièrement à 0.
|
||||
const rows = years.map(year => {
|
||||
const capitalDebut = capitalDebutByYear[year] ?? 0;
|
||||
|
||||
let capitalLabel;
|
||||
let capitalPourEcart;
|
||||
if (year < currentYear) {
|
||||
const capitalActuel = capitalActuelByYear[year] ?? 0;
|
||||
capitalPourEcart = capitalActuel;
|
||||
capitalLabel = <>{fmtEUR(capitalActuel)} au 31/12/{year}</>;
|
||||
} else if (year === currentYear) {
|
||||
const capitalActuel = capitalActuelByYear[year] ?? 0;
|
||||
const projete = projectedCapitalAt(`${year}-12-31`);
|
||||
capitalPourEcart = capitalActuel;
|
||||
capitalLabel = <>{fmtEUR(capitalActuel)} au {fmtDMY(today)} (Projection au 31/12/{year} : {fmtEUR(projete)})</>;
|
||||
} else {
|
||||
const projete = projectedCapitalAt(`${year}-12-31`);
|
||||
capitalPourEcart = projete;
|
||||
capitalLabel = <>{fmtEUR(projete)} projeté au 31/12/{year}</>;
|
||||
}
|
||||
|
||||
const objectif = objectifByYear[year];
|
||||
const ecart = objectif != null ? capitalPourEcart - objectif : null;
|
||||
|
||||
return { year, capitalDebut, capitalLabel, capitalPourEcart, objectif, ecart };
|
||||
}).filter(r => !(r.capitalDebut === 0 && r.capitalPourEcart === 0 && !(r.objectif > 0)));
|
||||
|
||||
return (
|
||||
<div className="card" style={{ marginTop: 20 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
|
||||
<h3 style={{ margin: 0 }}>{title}</h3>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-outline"
|
||||
onClick={() => navigate('/settings?section=objectifs')}
|
||||
style={{ fontSize: 'var(--fs-sm)' }}
|
||||
>
|
||||
Définir dans Paramétrage →
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ textAlign: 'left', fontSize: 'var(--fs-xs)', textTransform: 'uppercase', letterSpacing: '.06em', color: 'var(--text-muted)' }}>
|
||||
<th style={{ padding: '8px 10px' }}>Année</th>
|
||||
<th style={{ padding: '8px 10px' }}>Capital investi au 01/01</th>
|
||||
<th style={{ padding: '8px 10px' }}>Capital investi</th>
|
||||
<th style={{ padding: '8px 10px' }}>Objectifs (Capital)</th>
|
||||
<th style={{ padding: '8px 10px' }}>Atteinte</th>
|
||||
<th style={{ padding: '8px 10px' }}>Écart de capital investi</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map(({ year, capitalDebut, capitalLabel, capitalPourEcart, objectif, ecart }) => {
|
||||
return (
|
||||
<tr key={year} style={{ borderTop: '1px solid var(--border)' }}>
|
||||
<td style={{ padding: '8px 10px', fontWeight: 600 }}>
|
||||
{year}{year === currentYear && <span style={{ color: 'var(--text-muted)', fontWeight: 400 }}> (en cours)</span>}
|
||||
</td>
|
||||
<td style={{ padding: '8px 10px', color: 'var(--text-muted)' }}>{fmtEUR(capitalDebut)}</td>
|
||||
<td style={{ padding: '8px 10px', color: 'var(--text-muted)' }}>{capitalLabel}</td>
|
||||
<td style={{ padding: '8px 10px', color: objectif != null ? 'var(--text)' : 'var(--text-muted)' }}>
|
||||
{objectif != null ? fmtEUR(objectif) : 'Non défini'}
|
||||
</td>
|
||||
<td style={{ padding: '8px 10px' }}><TauxAtteinte realise={capitalPourEcart} objectif={objectif ?? null} /></td>
|
||||
<td style={{ padding: '8px 10px' }}><EcartCell ecart={ecart} /></td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{loading && <div style={{ padding: 8, color: 'var(--text-muted)', fontSize: 'var(--fs-sm)' }}>Chargement…</div>}
|
||||
<div style={{ marginTop: 10, fontSize: 'var(--fs-xs)', color: 'var(--text-muted)' }}>
|
||||
"Capital investi" est un solde à une date donnée, pas un flux — il ne se cumule pas d'une année sur l'autre.
|
||||
Les montants projetés (année en cours et années futures) sont calculés à partir des remboursements de capital prévus, non encore réalisés.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user