Mise en place des objectifs
This commit is contained in:
@@ -0,0 +1,276 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { api } from '../api.js';
|
||||
import { fmtEUR, memberLabel } from '../utils/format.js';
|
||||
|
||||
/*
|
||||
* SuiviObjectifs — tableau générique de suivi d'objectifs annuels.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* 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
|
||||
*/
|
||||
export default function SuiviObjectifs({
|
||||
rows = [],
|
||||
investisseurs = [],
|
||||
scopeInvestisseurIds = [],
|
||||
objectifType = 'versement_annuel',
|
||||
title = 'Suivi des objectifs de versement',
|
||||
}) {
|
||||
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 = () => {
|
||||
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]
|
||||
);
|
||||
|
||||
/* ── Années à afficher : celles avec mouvements + celles avec objectif + année courante ── */
|
||||
const years = useMemo(() => {
|
||||
const set = new Set(extraYears);
|
||||
for (const r of rows) {
|
||||
if (scopeInvestisseurIds.includes(r.investisseur_id) && r.date_operation) {
|
||||
set.add(Number(r.date_operation.slice(0, 4)));
|
||||
}
|
||||
}
|
||||
for (const o of objectifs) {
|
||||
if (scopeInvestisseurIds.includes(o.investisseur_id)) set.add(Number(o.annee));
|
||||
}
|
||||
set.add(new Date().getFullYear());
|
||||
return [...set].sort((a, b) => b - a);
|
||||
}, [rows, objectifs, scopeInvestisseurIds, extraYears]);
|
||||
|
||||
/* ── Totaux dépôts/retraits par année (scope courant) ── */
|
||||
const totalsByYear = useMemo(() => {
|
||||
const map = {};
|
||||
for (const year of years) map[year] = { depots: 0, retraits: 0 };
|
||||
for (const r of rows) {
|
||||
if (!scopeInvestisseurIds.includes(r.investisseur_id)) continue;
|
||||
const y = Number(r.date_operation?.slice(0, 4));
|
||||
if (!map[y]) continue;
|
||||
if (r.type === 'depot') map[y].depots += r.montant;
|
||||
else if (r.type === 'retrait') map[y].retraits += r.montant;
|
||||
}
|
||||
return map;
|
||||
}, [rows, years, scopeInvestisseurIds]);
|
||||
|
||||
/* ── Objectifs par année (scope courant) ── */
|
||||
const objectifsByYear = useMemo(() => {
|
||||
const map = {};
|
||||
for (const o of objectifs) {
|
||||
if (!scopeInvestisseurIds.includes(o.investisseur_id)) continue;
|
||||
if (!map[o.annee]) map[o.annee] = [];
|
||||
map[o.annee].push(o);
|
||||
}
|
||||
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 ── */
|
||||
const overallTotals = useMemo(() => {
|
||||
let depots = 0, retraits = 0;
|
||||
let objectifSum = 0, hasObjectif = false;
|
||||
let ecartSum = 0, hasEcart = false;
|
||||
for (const year of years) {
|
||||
const t = totalsByYear[year] || { depots: 0, retraits: 0 };
|
||||
depots += t.depots;
|
||||
retraits += t.retraits;
|
||||
const objs = objectifsByYear[year] || [];
|
||||
if (objs.length > 0) {
|
||||
const objectifTotal = objs.reduce((s, o) => s + o.montant, 0);
|
||||
objectifSum += objectifTotal;
|
||||
hasObjectif = true;
|
||||
ecartSum += (t.depots - t.retraits) - objectifTotal;
|
||||
hasEcart = true;
|
||||
}
|
||||
}
|
||||
return {
|
||||
depots, retraits, diff: depots - retraits,
|
||||
objectifTotal: hasObjectif ? objectifSum : null,
|
||||
ecart: hasEcart ? ecartSum : null,
|
||||
};
|
||||
}, [years, totalsByYear, objectifsByYear]);
|
||||
|
||||
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={addYear} style={{ fontSize: 'var(--fs-sm)' }}>
|
||||
+ Ajouter une année
|
||||
</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' }}>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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{years.map(year => {
|
||||
const t = totalsByYear[year] || { depots: 0, retraits: 0 };
|
||||
const diff = t.depots - t.retraits;
|
||||
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>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<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 }}>{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>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
{loading && <div style={{ padding: 8, color: 'var(--text-muted)', fontSize: 'var(--fs-sm)' }}>Chargement…</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import SoldeChart from '../components/SoldeChart.jsx';
|
||||
import DistributionChart from '../components/DistributionChart.jsx';
|
||||
import { fmtEUR, fmtDate, today } from '../utils/format.js';
|
||||
import DepotsMensuelTable from '../components/DepotsMensuelTable.jsx';
|
||||
import SuiviObjectifs from '../components/SuiviObjectifs.jsx';
|
||||
import * as XLSX from 'xlsx';
|
||||
|
||||
/* ── Helpers export ──────────────────────────────────────────── */
|
||||
@@ -397,6 +398,14 @@ export default function DepotsRetraits() {
|
||||
const [allCorrections, setAllCorrections] = useState([]);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState(null);
|
||||
|
||||
/* Objectifs de versement annuel — pour enrichir le KPI "Diff. Dépôts vs Retraits" */
|
||||
const [objectifsKpi, setObjectifsKpi] = useState([]);
|
||||
useEffect(() => {
|
||||
api.get('/objectifs', { type: 'versement_annuel' })
|
||||
.then(r => setObjectifsKpi(Array.isArray(r) ? r : []))
|
||||
.catch(() => setObjectifsKpi([]));
|
||||
}, []);
|
||||
|
||||
/* Lecture des params de navigation depuis la page Plateformes */
|
||||
useEffect(() => {
|
||||
const p = new URLSearchParams(search);
|
||||
@@ -681,6 +690,26 @@ export default function DepotsRetraits() {
|
||||
return balance;
|
||||
})();
|
||||
|
||||
/* ── Enrichissement du KPI "Diff. Dépôts vs Retraits" avec l'objectif de l'année ──
|
||||
Toujours calculé sur le portefeuille entier (ignore le filtre plateforme),
|
||||
pour l'année sélectionnée (drPlatYear, ou l'année en cours si "Toutes les années"). */
|
||||
const scopeInvestisseurIdsKpi = activeView === 'all' ? investisseurs.map(i => i.id) : [activeId];
|
||||
const kpiObjectifYear = Number(drPlatYear || new Date().getFullYear());
|
||||
const kpiObjectifTotal = (() => {
|
||||
const list = objectifsKpi.filter(o => o.annee === kpiObjectifYear && scopeInvestisseurIdsKpi.includes(o.investisseur_id));
|
||||
return list.length ? list.reduce((s, o) => s + o.montant, 0) : null;
|
||||
})();
|
||||
const kpiDiffGlobalYear = (() => {
|
||||
let d = 0, r = 0;
|
||||
for (const row of allRows) {
|
||||
if (!scopeInvestisseurIdsKpi.includes(row.investisseur_id)) continue;
|
||||
if (row.date_operation?.slice(0, 4) !== String(kpiObjectifYear)) continue;
|
||||
if (row.type === 'depot') d += row.montant; else r += row.montant;
|
||||
}
|
||||
return d - r;
|
||||
})();
|
||||
const kpiEcartObjectif = kpiObjectifTotal != null ? kpiDiffGlobalYear - kpiObjectifTotal : null;
|
||||
|
||||
const multiDetenteur = new Set(plats.map(p => p.investisseur_id)).size > 1;
|
||||
|
||||
/** Investisseur par défaut selon la vue active */
|
||||
@@ -966,6 +995,16 @@ export default function DepotsRetraits() {
|
||||
<TrendBadge current={totals.depots - totals.retraits} prev={prevTotals.depots - prevTotals.retraits} />
|
||||
</div>
|
||||
<div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{fmtEUR(prevTotals.depots - prevTotals.retraits)} en {prevYear}</div>
|
||||
{kpiEcartObjectif != null && (
|
||||
<div style={{
|
||||
fontSize: '0.8em', marginTop: 4, fontWeight: 600,
|
||||
color: kpiEcartObjectif >= 0 ? 'var(--success)' : 'var(--warning)',
|
||||
}}>
|
||||
{kpiEcartObjectif >= 0
|
||||
? `+${fmtEUR(kpiEcartObjectif)} au-delà de l'objectif ${kpiObjectifYear}`
|
||||
: `Reste ${fmtEUR(-kpiEcartObjectif)} pour l'objectif ${kpiObjectifYear}`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="kpi" title="Cash disponible sur les porte-monnaie des plateformes (hors compte courant)">
|
||||
<div className="label">Porte-monnaie</div>
|
||||
@@ -981,12 +1020,25 @@ export default function DepotsRetraits() {
|
||||
{!listFocused && <div className="dr-tabs">
|
||||
<button className={`dr-tab${activeTab === 'plateformes' ? ' active' : ''}`}
|
||||
onClick={() => setActiveTab('plateformes')}>Plateformes</button>
|
||||
<button className={`dr-tab${activeTab === 'vision-annuelle' ? ' active' : ''}`}
|
||||
onClick={() => setActiveTab('vision-annuelle')}>Vision annuelle</button>
|
||||
<button className={`dr-tab${activeTab === 'vision-mensuelle' ? ' active' : ''}`}
|
||||
onClick={() => setActiveTab('vision-mensuelle')}>Vision mensuelle</button>
|
||||
<button className={`dr-tab${activeTab === 'mouvements' ? ' active' : ''}`}
|
||||
onClick={() => { setActiveTab('mouvements'); setSelectedRow(r => r ?? (rows[0] || null)); }}>Mouvements</button>
|
||||
</div>}
|
||||
|
||||
{/* ====== ONGLET VISION ANNUELLE ====== */}
|
||||
{activeTab === 'vision-annuelle' && (
|
||||
<div style={{ padding: '0 24px' }}>
|
||||
<SuiviObjectifs
|
||||
rows={allRows}
|
||||
investisseurs={investisseurs}
|
||||
scopeInvestisseurIds={scopeInvestisseurIdsKpi}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ====== ONGLET PLATEFORMES ====== */}
|
||||
{activeTab === 'plateformes' && (
|
||||
<div style={{ padding: '0 24px' }}>
|
||||
|
||||
@@ -7,6 +7,7 @@ import PageIcon from '../components/PageIcon.jsx';
|
||||
import EmptyState from '../components/EmptyState.jsx';
|
||||
import InvChart from '../components/InvChart.jsx';
|
||||
import InvMensuelTable from '../components/InvMensuelTable.jsx';
|
||||
import SuiviObjectifs from '../components/SuiviObjectifs.jsx';
|
||||
import { fmtEUR, fmtDate, fmtStatut, memberLabel, today } from '../utils/format.js';
|
||||
import { xirr } from '../utils/xirr.js';
|
||||
import { usePagination } from '../hooks/usePagination.js';
|
||||
@@ -1510,6 +1511,14 @@ export default function Plateformes() {
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Suivi des objectifs de versement — toujours sur le portefeuille entier,
|
||||
indépendamment du filtre plateforme de cette page (cf. décision produit). */}
|
||||
<SuiviObjectifs
|
||||
rows={allDepots}
|
||||
investisseurs={investisseurs}
|
||||
scopeInvestisseurIds={activeView === 'all' ? investisseurs.map(i => i.id) : [activeId]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user