Améliorations diverses
This commit is contained in:
1 parent
8817ac693d
commit
49fab06012
15 files changed
+905
-144
No files matched your search
@@ -3445,6 +3445,18 @@ if (!invPeCols.includes('capital_investissable')) {
|
|||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mode de détention du deal PE (19/09/26, demande Olivier) — prépare le terrain pour un futur
|
||||||
|
// calcul fiscal PE sans le construire maintenant (cf. project_workspaces_transformation.md) :
|
||||||
|
// 'direct' (compte-titres classique, flat tax au moment du versement, ex. Fundora) | 'pea_pme'
|
||||||
|
// (enveloppe PEA/PEA-PME, ex. Baltis) | 'assurance_vie'. Même principe que fiscalite_override
|
||||||
|
// côté crowdlending (cf. project_fiscalite_override.md), mais en énumération à 3 valeurs plutôt
|
||||||
|
// qu'un booléen "exonéré ou pas" : PEA-PME et assurance-vie ont des régimes fiscaux différents
|
||||||
|
// l'un de l'autre, pas juste "exonéré ou pas". Informatif pour l'instant, aucun calcul fiscal
|
||||||
|
// n'en dépend encore.
|
||||||
|
if (!invPeCols.includes('mode_detention')) {
|
||||||
|
db.exec("ALTER TABLE investissements_pe ADD COLUMN mode_detention TEXT NOT NULL DEFAULT 'direct'");
|
||||||
|
}
|
||||||
|
|
||||||
// Lien optionnel remboursement → deal PE (17/09/26) : une distribution (retour
|
// Lien optionnel remboursement → deal PE (17/09/26) : une distribution (retour
|
||||||
// de capital, plus-value) perçue sur un deal PE est saisie comme un
|
// de capital, plus-value) perçue sur un deal PE est saisie comme un
|
||||||
// remboursement bonus_plateforme/interets_plateforme, comme demandé par
|
// remboursement bonus_plateforme/interets_plateforme, comme demandé par
|
||||||
|
|||||||
@@ -106,12 +106,18 @@ function assertUniqueSouscription(body, categorie, excludeId) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/frais-operations?investissement_id=123 | ?investissement_pe_id=456
|
* GET /api/frais-operations?investissement_id=123 | ?investissement_pe_id=456
|
||||||
* Sans filtre : tous les frais de l'utilisateur pour le workspace ACTIF (résolu via
|
* Sans filtre : tous les frais du détenteur actif (header X-Investisseur-Id) pour le
|
||||||
* resolveActiveWorkspaceId, header X-Workspace-Id) — crowdlending ou PE selon le slug
|
* workspace ACTIF (résolu via resolveActiveWorkspaceId, header X-Workspace-Id) — crowdlending
|
||||||
* du workspace, enrichis de nom_projet/nom_deal + plateforme_nom/investisseur_nom (vue
|
* ou PE selon le type du workspace, enrichis de nom_projet/nom_deal + plateforme_nom/
|
||||||
* portefeuille, ex. page dédiée Frais/FraisPe). Le filtre par investissement_id/
|
* investisseur_nom (vue portefeuille, ex. page dédiée Frais/FraisPe). ?scope=all agrège tous
|
||||||
|
* les détenteurs de l'utilisateur (vue "Famille et entreprises"), même mécanisme que GET
|
||||||
|
* /investissements-pe, /depots-retraits et /remboursements (19/09/26 — jusqu'ici cette route
|
||||||
|
* ignorait totalement le détenteur actif et renvoyait toujours tout le foyer, contrairement à
|
||||||
|
* ces trois autres routes ; bug remonté par Olivier : la page Frais ne filtrait jamais par
|
||||||
|
* détenteur, même un détenteur précis sélectionné). Le filtre par investissement_id/
|
||||||
* investissement_pe_id n'a pas besoin de cet enrichissement, le contexte est déjà connu
|
* investissement_pe_id n'a pas besoin de cet enrichissement, le contexte est déjà connu
|
||||||
* côté client.
|
* côté client, et reste volontairement family-wide (propriété déjà vérifiée par
|
||||||
|
* assertOwnedInvestissement(Pe) sur le deal précis demandé).
|
||||||
*/
|
*/
|
||||||
router.get('/', (req, res, next) => {
|
router.get('/', (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
@@ -138,6 +144,22 @@ router.get('/', (req, res, next) => {
|
|||||||
} else {
|
} else {
|
||||||
const workspaceId = resolveActiveWorkspaceId(req);
|
const workspaceId = resolveActiveWorkspaceId(req);
|
||||||
const ws = db.prepare('SELECT type FROM workspaces WHERE id = ?').get(workspaceId);
|
const ws = db.prepare('SELECT type FROM workspaces WHERE id = ?').get(workspaceId);
|
||||||
|
|
||||||
|
const scopeAll = req.query.scope === 'all';
|
||||||
|
let invCond, invArgs;
|
||||||
|
if (scopeAll) {
|
||||||
|
invCond = 'inv.user_id = ?';
|
||||||
|
invArgs = [req.user.id];
|
||||||
|
} else {
|
||||||
|
const raw = req.header('X-Investisseur-Id');
|
||||||
|
const id = Number(raw);
|
||||||
|
if (!id) return res.status(400).json({ error: 'Missing investisseur id (header X-Investisseur-Id)' });
|
||||||
|
const row = db.prepare('SELECT id FROM investisseurs WHERE id = ? AND user_id = ?').get(id, req.user.id);
|
||||||
|
if (!row) return res.status(403).json({ error: 'Investisseur not found or not owned by user' });
|
||||||
|
invCond = 'inv.id = ?';
|
||||||
|
invArgs = [id];
|
||||||
|
}
|
||||||
|
|
||||||
if (!ws || ws.type === 'crowdlending') {
|
if (!ws || ws.type === 'crowdlending') {
|
||||||
rows = db.prepare(`
|
rows = db.prepare(`
|
||||||
SELECT f.*, c.nom AS compte_nom, i.nom_projet, p.nom AS plateforme_nom, inv.nom AS investisseur_nom
|
SELECT f.*, c.nom AS compte_nom, i.nom_projet, p.nom AS plateforme_nom, inv.nom AS investisseur_nom
|
||||||
@@ -146,9 +168,9 @@ router.get('/', (req, res, next) => {
|
|||||||
JOIN investisseurs inv ON inv.id = i.investisseur_id
|
JOIN investisseurs inv ON inv.id = i.investisseur_id
|
||||||
JOIN plateformes p ON p.id = i.plateforme_id
|
JOIN plateformes p ON p.id = i.plateforme_id
|
||||||
LEFT JOIN comptes c ON c.id = f.compte_id
|
LEFT JOIN comptes c ON c.id = f.compte_id
|
||||||
WHERE inv.user_id = ?
|
WHERE ${invCond}
|
||||||
ORDER BY f.date_operation DESC, f.id DESC
|
ORDER BY f.date_operation DESC, f.id DESC
|
||||||
`).all(req.user.id);
|
`).all(...invArgs);
|
||||||
} else {
|
} else {
|
||||||
rows = db.prepare(`
|
rows = db.prepare(`
|
||||||
SELECT f.*, c.nom AS compte_nom, ipe.nom_deal, p.nom AS plateforme_nom, inv.nom AS investisseur_nom
|
SELECT f.*, c.nom AS compte_nom, ipe.nom_deal, p.nom AS plateforme_nom, inv.nom AS investisseur_nom
|
||||||
@@ -157,9 +179,9 @@ router.get('/', (req, res, next) => {
|
|||||||
JOIN investisseurs inv ON inv.id = ipe.investisseur_id
|
JOIN investisseurs inv ON inv.id = ipe.investisseur_id
|
||||||
JOIN plateformes p ON p.id = ipe.plateforme_id
|
JOIN plateformes p ON p.id = ipe.plateforme_id
|
||||||
LEFT JOIN comptes c ON c.id = f.compte_id
|
LEFT JOIN comptes c ON c.id = f.compte_id
|
||||||
WHERE inv.user_id = ? AND ipe.workspace_id = ?
|
WHERE ${invCond} AND ipe.workspace_id = ?
|
||||||
ORDER BY f.date_operation DESC, f.id DESC
|
ORDER BY f.date_operation DESC, f.id DESC
|
||||||
`).all(req.user.id, workspaceId);
|
`).all(...invArgs, workspaceId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
res.json(rows);
|
res.json(rows);
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ const Schema = z.object({
|
|||||||
statut: z.enum(['en_attente', 'valide', 'cloture', 'perte_definitive']).default('valide'),
|
statut: z.enum(['en_attente', 'valide', 'cloture', 'perte_definitive']).default('valide'),
|
||||||
reference: z.string().optional(),
|
reference: z.string().optional(),
|
||||||
notes: z.string().optional(),
|
notes: z.string().optional(),
|
||||||
|
// Mode de détention (19/09/26) — cf. migration mode_detention dans db/index.js.
|
||||||
|
mode_detention: z.enum(['direct', 'pea_pme', 'assurance_vie']).default('direct'),
|
||||||
});
|
});
|
||||||
|
|
||||||
const ValorisationSchema = z.object({
|
const ValorisationSchema = z.object({
|
||||||
@@ -102,6 +104,11 @@ router.get('/', (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const workspaceId = resolveActiveWorkspaceId(req);
|
const workspaceId = resolveActiveWorkspaceId(req);
|
||||||
|
// Filtre optionnel par plateforme (19/09/26, page Plateformes PE) — mirroir du filtre
|
||||||
|
// plateforme_id déjà supporté par GET /depots-retraits et GET /remboursements.
|
||||||
|
const { plateforme_id } = req.query;
|
||||||
|
const platCond = plateforme_id ? 'AND ipe.plateforme_id = ?' : '';
|
||||||
|
const platArgs = plateforme_id ? [Number(plateforme_id)] : [];
|
||||||
|
|
||||||
const rows = db.prepare(`
|
const rows = db.prepare(`
|
||||||
SELECT ipe.*,
|
SELECT ipe.*,
|
||||||
@@ -126,9 +133,9 @@ router.get('/', (req, res) => {
|
|||||||
FROM investissements_pe ipe
|
FROM investissements_pe ipe
|
||||||
JOIN plateformes p ON p.id = ipe.plateforme_id
|
JOIN plateformes p ON p.id = ipe.plateforme_id
|
||||||
JOIN investisseurs inv ON inv.id = ipe.investisseur_id
|
JOIN investisseurs inv ON inv.id = ipe.investisseur_id
|
||||||
WHERE ${invCond} AND ipe.workspace_id = ?
|
WHERE ${invCond} AND ipe.workspace_id = ? ${platCond}
|
||||||
ORDER BY ipe.date_souscription DESC, ipe.id DESC
|
ORDER BY ipe.date_souscription DESC, ipe.id DESC
|
||||||
`).all(...invArgs, workspaceId);
|
`).all(...invArgs, workspaceId, ...platArgs);
|
||||||
|
|
||||||
const withTvpi = rows.map(r => ({
|
const withTvpi = rows.map(r => ({
|
||||||
...r,
|
...r,
|
||||||
@@ -252,7 +259,15 @@ router.get('/mouvements-portefeuille', (req, res) => {
|
|||||||
// (crowdlending) qui appelle cet endpoint. Pour depots_retraits en revanche (table partagée
|
// (crowdlending) qui appelle cet endpoint. Pour depots_retraits en revanche (table partagée
|
||||||
// entre workspaces), il FAUT exclure explicitement le workspace appelant, pour ne pas
|
// entre workspaces), il FAUT exclure explicitement le workspace appelant, pour ne pas
|
||||||
// dupliquer ce que le frontend a déjà récupéré via son propre GET /depots-retraits scopé.
|
// dupliquer ce que le frontend a déjà récupéré via son propre GET /depots-retraits scopé.
|
||||||
|
//
|
||||||
|
// ?native=1 (19/09/26, page Plateformes PE) : bascule ce même endpoint pour un appel fait
|
||||||
|
// DEPUIS le workspace PE lui-même (plutôt que depuis crowdlending) — dans ce sens, les
|
||||||
|
// depots_retraits à renvoyer sont ceux DU workspace appelant (workspace_id = callerWorkspaceId),
|
||||||
|
// pas ceux d'un autre workspace. Les souscriptions/distributions/frais PE restent inchangées
|
||||||
|
// (déjà natives, sans filtre workspace_id — limite connue si plusieurs workspaces PE existent
|
||||||
|
// un jour, cf. commentaire ci-dessus, non résolue ici).
|
||||||
const callerWorkspaceId = resolveActiveWorkspaceId(req);
|
const callerWorkspaceId = resolveActiveWorkspaceId(req);
|
||||||
|
const native = req.query.native === '1';
|
||||||
|
|
||||||
const souscriptions = db.prepare(`
|
const souscriptions = db.prepare(`
|
||||||
SELECT ipe.id AS deal_id, ipe.plateforme_id, p.nom AS plateforme_nom,
|
SELECT ipe.id AS deal_id, ipe.plateforme_id, p.nom AS plateforme_nom,
|
||||||
@@ -292,6 +307,7 @@ router.get('/mouvements-portefeuille', (req, res) => {
|
|||||||
WHERE ${invCond}
|
WHERE ${invCond}
|
||||||
`).all(...invArgs);
|
`).all(...invArgs);
|
||||||
|
|
||||||
|
const drWorkspaceOp = native ? '=' : '!=';
|
||||||
const depots = db.prepare(`
|
const depots = db.prepare(`
|
||||||
SELECT dr.id AS dr_id, dr.plateforme_id, p.nom AS plateforme_nom,
|
SELECT dr.id AS dr_id, dr.plateforme_id, p.nom AS plateforme_nom,
|
||||||
plat_inv.nom AS plateforme_detenteur_nom,
|
plat_inv.nom AS plateforme_detenteur_nom,
|
||||||
@@ -301,7 +317,7 @@ router.get('/mouvements-portefeuille', (req, res) => {
|
|||||||
JOIN plateformes p ON p.id = dr.plateforme_id
|
JOIN plateformes p ON p.id = dr.plateforme_id
|
||||||
LEFT JOIN investisseurs plat_inv ON plat_inv.id = p.investisseur_id
|
LEFT JOIN investisseurs plat_inv ON plat_inv.id = p.investisseur_id
|
||||||
JOIN investisseurs inv ON inv.id = dr.investisseur_id
|
JOIN investisseurs inv ON inv.id = dr.investisseur_id
|
||||||
WHERE dr.workspace_id != ? AND ${invCondDr}
|
WHERE dr.workspace_id ${drWorkspaceOp} ? AND ${invCondDr}
|
||||||
`).all(callerWorkspaceId, ...invArgsDr);
|
`).all(callerWorkspaceId, ...invArgsDr);
|
||||||
|
|
||||||
const rows = [
|
const rows = [
|
||||||
@@ -342,13 +358,14 @@ router.post('/', requireInvestisseur, (req, res, next) => {
|
|||||||
const r = db.prepare(`
|
const r = db.prepare(`
|
||||||
INSERT INTO investissements_pe
|
INSERT INTO investissements_pe
|
||||||
(investisseur_id, plateforme_id, workspace_id, nom_deal, strategie, date_souscription,
|
(investisseur_id, plateforme_id, workspace_id, nom_deal, strategie, date_souscription,
|
||||||
duree_mois, montant_investi, capital_investissable, objectif, statut, reference, notes)
|
duree_mois, montant_investi, capital_investissable, objectif, statut, reference, notes,
|
||||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)
|
mode_detention)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||||
`).run(
|
`).run(
|
||||||
investisseurId, body.plateforme_id, workspaceId, body.nom_deal, body.strategie || null,
|
investisseurId, body.plateforme_id, workspaceId, body.nom_deal, body.strategie || null,
|
||||||
body.date_souscription, body.duree_mois ?? null, body.montant_investi,
|
body.date_souscription, body.duree_mois ?? null, body.montant_investi,
|
||||||
body.capital_investissable ?? null, body.objectif || null,
|
body.capital_investissable ?? null, body.objectif || null,
|
||||||
body.statut, body.reference || null, body.notes || null,
|
body.statut, body.reference || null, body.notes || null, body.mode_detention,
|
||||||
);
|
);
|
||||||
res.status(201).json({ id: r.lastInsertRowid, ...body });
|
res.status(201).json({ id: r.lastInsertRowid, ...body });
|
||||||
} catch (e) { next(e); }
|
} catch (e) { next(e); }
|
||||||
@@ -505,12 +522,13 @@ router.put('/:id', requireInvestisseur, (req, res, next) => {
|
|||||||
UPDATE investissements_pe
|
UPDATE investissements_pe
|
||||||
SET investisseur_id=?, plateforme_id=?, nom_deal=?, strategie=?, date_souscription=?,
|
SET investisseur_id=?, plateforme_id=?, nom_deal=?, strategie=?, date_souscription=?,
|
||||||
duree_mois=?, montant_investi=?, capital_investissable=?, objectif=?, statut=?,
|
duree_mois=?, montant_investi=?, capital_investissable=?, objectif=?, statut=?,
|
||||||
reference=?, notes=?, updated_at=datetime('now')
|
reference=?, notes=?, mode_detention=?, updated_at=datetime('now')
|
||||||
WHERE id=?
|
WHERE id=?
|
||||||
`).run(
|
`).run(
|
||||||
investisseurId, body.plateforme_id, body.nom_deal, body.strategie || null, body.date_souscription,
|
investisseurId, body.plateforme_id, body.nom_deal, body.strategie || null, body.date_souscription,
|
||||||
body.duree_mois ?? null, body.montant_investi, body.capital_investissable ?? null,
|
body.duree_mois ?? null, body.montant_investi, body.capital_investissable ?? null,
|
||||||
body.objectif || null, body.statut, body.reference || null, body.notes || null, id,
|
body.objectif || null, body.statut, body.reference || null, body.notes || null,
|
||||||
|
body.mode_detention, id,
|
||||||
);
|
);
|
||||||
res.json({ id, ...body });
|
res.json({ id, ...body });
|
||||||
} catch (e) { next(e); }
|
} catch (e) { next(e); }
|
||||||
|
|||||||
+12
-1
@@ -32,6 +32,7 @@ import AdminIA from './pages/AdminIA.jsx';
|
|||||||
import Aide from './pages/Aide.jsx';
|
import Aide from './pages/Aide.jsx';
|
||||||
import PlatformeProfile from './pages/PlatformeProfile.jsx';
|
import PlatformeProfile from './pages/PlatformeProfile.jsx';
|
||||||
import Plateformes from './pages/Plateformes.jsx';
|
import Plateformes from './pages/Plateformes.jsx';
|
||||||
|
import PlateformesPe from './pages/PlateformesPe.jsx';
|
||||||
import Notifications from './pages/Notifications.jsx';
|
import Notifications from './pages/Notifications.jsx';
|
||||||
import Communication from './pages/Communication.jsx';
|
import Communication from './pages/Communication.jsx';
|
||||||
import AssistantIA from './pages/AssistantIA.jsx';
|
import AssistantIA from './pages/AssistantIA.jsx';
|
||||||
@@ -153,6 +154,16 @@ function FraisRouter() {
|
|||||||
return <FraisOnly><Frais /></FraisOnly>;
|
return <FraisOnly><Frais /></FraisOnly>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Même principe (page Plateformes PE, 19/09/26, demande Olivier) : la page Plateformes
|
||||||
|
// bascule sur PlateformesPe.jsx pour tout workspace autre que crowdlending — plus de
|
||||||
|
// RequireWorkspace en dur sur cette route (elle était jusqu'ici réservée au crowdlending,
|
||||||
|
// cf. project_workspaces_transformation.md).
|
||||||
|
function PlateformesRouter() {
|
||||||
|
const { active } = useWorkspace() || {};
|
||||||
|
if (active && active.type !== 'crowdlending') return <PlateformesPe />;
|
||||||
|
return <Plateformes />;
|
||||||
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [allowRegistration, setAllowRegistration] = useState(true);
|
const [allowRegistration, setAllowRegistration] = useState(true);
|
||||||
|
|
||||||
@@ -197,7 +208,7 @@ export default function App() {
|
|||||||
<Route path="/verify-email" element={<VerifyEmail />} />
|
<Route path="/verify-email" element={<VerifyEmail />} />
|
||||||
<Route element={<Protected><Layout /></Protected>}>
|
<Route element={<Protected><Layout /></Protected>}>
|
||||||
<Route index element={<WorkspaceHome />} />
|
<Route index element={<WorkspaceHome />} />
|
||||||
<Route path="plateformes" element={<RequireWorkspace type="crowdlending"><Plateformes /></RequireWorkspace>} />
|
<Route path="plateformes" element={<PlateformesRouter />} />
|
||||||
<Route path="depots-retraits" element={<DepotsRetraitsRouter />} />
|
<Route path="depots-retraits" element={<DepotsRetraitsRouter />} />
|
||||||
<Route path="investissements" element={<InvestissementsRouter />} />
|
<Route path="investissements" element={<InvestissementsRouter />} />
|
||||||
<Route path="investissements/:id" element={<InvestissementDetailRouter />} />
|
<Route path="investissements/:id" element={<InvestissementDetailRouter />} />
|
||||||
|
|||||||
@@ -149,6 +149,14 @@ export default function InvestissementPeFormModal({
|
|||||||
<label>Référence</label>
|
<label>Référence</label>
|
||||||
<input value={form.reference} onChange={e => setField('reference', e.target.value)} />
|
<input value={form.reference} onChange={e => setField('reference', e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Mode de détention</label>
|
||||||
|
<select value={form.mode_detention} onChange={e => setField('mode_detention', e.target.value)}>
|
||||||
|
<option value="direct">Direct (compte-titres)</option>
|
||||||
|
<option value="pea_pme">Enveloppe PEA / PEA-PME</option>
|
||||||
|
<option value="assurance_vie">Enveloppe Assurance-vie</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<div style={{ gridColumn: '1 / -1' }}>
|
<div style={{ gridColumn: '1 / -1' }}>
|
||||||
<label>Détenteur *</label>
|
<label>Détenteur *</label>
|
||||||
<select required value={form.investisseur_id} onChange={e => setField('investisseur_id', e.target.value)}>
|
<select required value={form.investisseur_id} onChange={e => setField('investisseur_id', e.target.value)}>
|
||||||
|
|||||||
@@ -344,23 +344,16 @@ export default function Layout() {
|
|||||||
<NavLink to="/" end title="Tableau de bord">
|
<NavLink to="/" end title="Tableau de bord">
|
||||||
<NavIcon libFilename={navIcons.dashboard} Fallback={IconDashboard} /><span className="nav-label">Tableau de bord</span>
|
<NavIcon libFilename={navIcons.dashboard} Fallback={IconDashboard} /><span className="nav-label">Tableau de bord</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
{/* Plateformes reste propre au crowdlending pour l'instant (pas
|
{/* Plateformes (19/09/26) a désormais son équivalent PE (PlateformesPe.jsx,
|
||||||
d'équivalent PE — les plateformes PE se gèrent depuis Mes
|
PlateformesRouter dans App.jsx) — visible quel que soit le workspace actif, comme
|
||||||
plateformes, settings). Investissements, Dépôts/Retraits et
|
Investissements, Dépôts/Retraits et Remboursements (17/09/26). Chaque lien reste
|
||||||
Remboursements (17/09/26) sont en revanche visibles quel que soit
|
conditionné individuellement plutôt que regroupé dans un seul bloc
|
||||||
le workspace actif — la route bascule automatiquement sur la
|
isCrowdlendingActive, pour conserver l'ordre historique du menu crowdlending
|
||||||
version adaptée (InvestissementsRouter / DepotsRetraitsRouter /
|
(Plateformes, Investissements, Dépôts/Retraits, Remboursements, Frais...) au lieu
|
||||||
RemboursementsRouter, App.jsx). Chaque lien est conditionné
|
de faire remonter ces liens en tête — cf. capture d'Olivier, 17/09/26. */}
|
||||||
individuellement plutôt que regroupé dans un seul bloc
|
|
||||||
isCrowdlendingActive, pour conserver l'ordre historique du menu
|
|
||||||
crowdlending (Plateformes, Investissements, Dépôts/Retraits,
|
|
||||||
Remboursements, Frais...) au lieu de faire remonter ces liens en
|
|
||||||
tête — cf. capture d'Olivier, 17/09/26. */}
|
|
||||||
{isCrowdlendingActive && (
|
|
||||||
<NavLink to="/plateformes" title="Plateformes">
|
<NavLink to="/plateformes" title="Plateformes">
|
||||||
<NavIcon libFilename={navIcons.plateforme} Fallback={IconInvestments} /><span className="nav-label">Plateformes</span>
|
<NavIcon libFilename={navIcons.plateforme} Fallback={IconInvestments} /><span className="nav-label">Plateformes</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
)}
|
|
||||||
<NavLink to="/investissements" title="Investissements">
|
<NavLink to="/investissements" title="Investissements">
|
||||||
<NavIcon libFilename={navIcons.investissement} Fallback={IconInvestments} /><span className="nav-label">Investissements</span>
|
<NavIcon libFilename={navIcons.investissement} Fallback={IconInvestments} /><span className="nav-label">Investissements</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
const LOGOS_BASE = '/api/logos/';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Résout le fichier d'icône d'une plateforme, quel que soit le nom de champ utilisé par
|
||||||
|
* l'appelant (19/09/26, cf. project_workspaces_transformation.md — bug corrigé : la page
|
||||||
|
* Plateformes PE passait les lignes brutes de GET /plateformes, dont le champ réel est
|
||||||
|
* `icone_filename` et non `icon_filename`, et retombait donc sur `logo_filename`, un visuel
|
||||||
|
* plus large pensé pour un bandeau d'en-tête plutôt que pour une icône carrée) :
|
||||||
|
* - icon_filename : nom déjà normalisé par l'appelant (ex. platOptions de Plateformes.jsx,
|
||||||
|
* qui déduplique par plateforme et calcule ce champ lui-même) ;
|
||||||
|
* - icone_filename : champ réel de la table `plateformes` (icône carrée dédiée) ;
|
||||||
|
* - logo_filename : repli, pensé pour un bandeau large, mais mieux que rien si aucune icône
|
||||||
|
* carrée n'a été téléversée pour cette plateforme.
|
||||||
|
*/
|
||||||
|
function iconSrc(p) {
|
||||||
|
const filename = p?.icon_filename || p?.icone_filename || p?.logo_filename || null;
|
||||||
|
return filename ? `${LOGOS_BASE}${filename}` : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sélecteur "Plateforme" — carte dégradée avec icône + nom, liste déroulante au clic.
|
||||||
|
* Composant partagé entre Plateformes.jsx (crowdlending) et PlateformesPe.jsx (Private Equity)
|
||||||
|
* pour éviter la dérive entre deux copies (même principe que MouvementsShared.jsx) : les deux
|
||||||
|
* pages passent leurs propres `platOptions` (formes différentes — platOptions dédupliqué par
|
||||||
|
* investissement côté crowdlending, lignes `plateformes` brutes filtrées par workspace côté PE)
|
||||||
|
* mais l'identité visuelle et la résolution d'icône restent identiques partout.
|
||||||
|
*/
|
||||||
|
export default function PlatSelector({ platOptions, selectedPlatName, onSelect }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const ref = useRef(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const h = e => { if (!ref.current?.contains(e.target)) setOpen(false); };
|
||||||
|
document.addEventListener('mousedown', h);
|
||||||
|
return () => document.removeEventListener('mousedown', h);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const selected = platOptions.find(p => p.nom === selectedPlatName) || platOptions[0];
|
||||||
|
const selectedIcon = iconSrc(selected);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} style={{ position: 'relative' }}>
|
||||||
|
<div
|
||||||
|
onClick={() => setOpen(v => !v)}
|
||||||
|
style={{
|
||||||
|
background: 'linear-gradient(135deg, var(--primary) 0%, var(--primary-hover) 100%)',
|
||||||
|
borderRadius: 10, padding: '12px 16px',
|
||||||
|
boxShadow: open
|
||||||
|
? '0 6px 28px color-mix(in srgb, var(--primary) 45%, transparent)'
|
||||||
|
: '0 4px 20px color-mix(in srgb, var(--primary) 30%, transparent)',
|
||||||
|
cursor: 'pointer', userSelect: 'none', transition: 'box-shadow .15s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 }}>
|
||||||
|
<span style={{ fontSize: 'var(--fs-xs)', textTransform: 'uppercase', letterSpacing: '.06em', color: 'rgba(255,255,255,0.7)', fontWeight: 500 }}>
|
||||||
|
Plateforme
|
||||||
|
</span>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
|
||||||
|
stroke="rgba(255,255,255,0.7)" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"
|
||||||
|
style={{ transform: open ? 'rotate(180deg)' : 'none', transition: 'transform .2s', flexShrink: 0 }}>
|
||||||
|
<polyline points="6 9 12 15 18 9"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
|
{selectedIcon && (
|
||||||
|
<img src={selectedIcon}
|
||||||
|
width={28} height={28}
|
||||||
|
style={{ objectFit: 'contain', borderRadius: 4, background: '#fff', padding: 2, flexShrink: 0 }}
|
||||||
|
alt="" />
|
||||||
|
)}
|
||||||
|
<span style={{ color: '#fff', fontSize: '1.1rem', fontWeight: 700, lineHeight: 1.2 }}>
|
||||||
|
{selected?.nom || '—'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', top: 'calc(100% + 6px)', left: 0, right: 0, zIndex: 200,
|
||||||
|
background: 'var(--surface)', border: '1px solid var(--border)',
|
||||||
|
borderRadius: 10, boxShadow: '0 8px 28px rgba(0,0,0,0.15)', overflow: 'hidden',
|
||||||
|
maxHeight: 280, overflowY: 'auto',
|
||||||
|
}}>
|
||||||
|
{platOptions.map(p => {
|
||||||
|
const icon = iconSrc(p);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={p.nom}
|
||||||
|
onClick={() => { onSelect(p.nom); setOpen(false); }}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 10,
|
||||||
|
padding: '10px 14px', cursor: 'pointer',
|
||||||
|
background: p.nom === selectedPlatName ? 'var(--surface-2)' : 'transparent',
|
||||||
|
fontWeight: p.nom === selectedPlatName ? 700 : 400,
|
||||||
|
fontSize: 'var(--fs-sm)',
|
||||||
|
borderBottom: '1px solid var(--border)',
|
||||||
|
transition: 'background .1s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { if (p.nom !== selectedPlatName) e.currentTarget.style.background = 'var(--surface-2)'; }}
|
||||||
|
onMouseLeave={e => { if (p.nom !== selectedPlatName) e.currentTarget.style.background = 'transparent'; }}
|
||||||
|
>
|
||||||
|
{icon ? (
|
||||||
|
<img src={icon}
|
||||||
|
width={20} height={20}
|
||||||
|
style={{ objectFit: 'contain', flexShrink: 0, opacity: 0.8 }}
|
||||||
|
alt="" />
|
||||||
|
) : (
|
||||||
|
<span style={{ width: 20, height: 20, borderRadius: 4, background: 'var(--surface-2)', flexShrink: 0, display: 'inline-block' }} />
|
||||||
|
)}
|
||||||
|
<span>{p.nom}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -45,7 +45,7 @@ import { emptyMouvementForm } from '../components/MouvementsShared.jsx';
|
|||||||
* investissements, sans corrections/réinvestissements/frais_operations.
|
* investissements, sans corrections/réinvestissements/frais_operations.
|
||||||
*/
|
*/
|
||||||
export default function DepotsRetraitsSimple() {
|
export default function DepotsRetraitsSimple() {
|
||||||
const { activeId: investisseurId, investisseurs } = useInvestisseur();
|
const { activeId: investisseurId, activeView, investisseurs } = useInvestisseur();
|
||||||
const { active: activeWorkspace } = useWorkspace() || {};
|
const { active: activeWorkspace } = useWorkspace() || {};
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -83,10 +83,11 @@ export default function DepotsRetraitsSimple() {
|
|||||||
const load = async () => {
|
const load = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
|
const scopeParams = activeView === 'all' ? { scope: 'all' } : {};
|
||||||
const [dr, pl, dash] = await Promise.all([
|
const [dr, pl, dash] = await Promise.all([
|
||||||
api.get('/depots-retraits'),
|
api.get('/depots-retraits', scopeParams),
|
||||||
api.get('/plateformes'),
|
api.get('/plateformes'),
|
||||||
api.get('/dashboard-pe'),
|
api.get('/dashboard-pe', scopeParams),
|
||||||
]);
|
]);
|
||||||
setRows(dr);
|
setRows(dr);
|
||||||
setAllPlats(pl);
|
setAllPlats(pl);
|
||||||
@@ -99,9 +100,10 @@ export default function DepotsRetraitsSimple() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (investisseurId) load();
|
if (!investisseurId && activeView !== 'all') return;
|
||||||
|
load();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [investisseurId, activeWorkspace?.id]);
|
}, [investisseurId, activeView, activeWorkspace?.id]);
|
||||||
|
|
||||||
const years = useMemo(
|
const years = useMemo(
|
||||||
() => [...new Set(rows.map(r => r.date_operation.slice(0, 4)))].sort().reverse(),
|
() => [...new Set(rows.map(r => r.date_operation.slice(0, 4)))].sort().reverse(),
|
||||||
@@ -113,15 +115,15 @@ export default function DepotsRetraitsSimple() {
|
|||||||
const prevYear = String(Number(platYear || new Date().getFullYear()) - 1);
|
const prevYear = String(Number(platYear || new Date().getFullYear()) - 1);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!platYear) return;
|
if (!platYear) return;
|
||||||
|
const scopeParams = activeView === 'all' ? { scope: 'all' } : {};
|
||||||
[platYear, prevYear].forEach(y => {
|
[platYear, prevYear].forEach(y => {
|
||||||
const key = `${y}-12-31`;
|
const key = `${y}-12-31`;
|
||||||
if (dashDataAsOf[key]) return;
|
api.get('/dashboard-pe', { ...scopeParams, asOf: key })
|
||||||
api.get('/dashboard-pe', { asOf: key })
|
|
||||||
.then(snap => setDashDataAsOf(prev => ({ ...prev, [key]: snap })))
|
.then(snap => setDashDataAsOf(prev => ({ ...prev, [key]: snap })))
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
});
|
});
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [platYear, investisseurId, activeWorkspace?.id]);
|
}, [platYear, investisseurId, activeView, activeWorkspace?.id]);
|
||||||
|
|
||||||
const investisseurForPlat = (platId) => {
|
const investisseurForPlat = (platId) => {
|
||||||
const plat = plats.find(p => String(p.id) === String(platId));
|
const plat = plats.find(p => String(p.id) === String(platId));
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export default function Frais() {
|
|||||||
try {
|
try {
|
||||||
const scopeParams = activeView === 'all' ? { scope: 'all' } : {};
|
const scopeParams = activeView === 'all' ? { scope: 'all' } : {};
|
||||||
const [f, inv] = await Promise.all([
|
const [f, inv] = await Promise.all([
|
||||||
api.get('/frais-operations'),
|
api.get('/frais-operations', scopeParams),
|
||||||
api.get('/investissements', scopeParams),
|
api.get('/investissements', scopeParams),
|
||||||
]);
|
]);
|
||||||
setAllFraisOps(f);
|
setAllFraisOps(f);
|
||||||
|
|||||||
@@ -19,9 +19,12 @@ import { fmtEUR, fmtDate, today } from '../utils/format.js';
|
|||||||
* (crowdlending) — même structure (graphiques, KPI, onglets Plateformes / Typologie /
|
* (crowdlending) — même structure (graphiques, KPI, onglets Plateformes / Typologie /
|
||||||
* Investissements / Opérations), parité complète demandée explicitement par Olivier — avec
|
* Investissements / Opérations), parité complète demandée explicitement par Olivier — avec
|
||||||
* les différences suivantes :
|
* les différences suivantes :
|
||||||
* - source des données : /investissements-pe (deals) au lieu de /investissements, et
|
* - source des données : /investissements-pe (deals) au lieu de /investissements. GET
|
||||||
* GET /frais-operations sans filtre se borne déjà au workspace ACTIF côté backend
|
* /frais-operations sans filtre se borne au workspace ACTIF côté backend
|
||||||
* (resolveActiveWorkspaceId), donc renvoie naturellement les frais PE ici ;
|
* (resolveActiveWorkspaceId) et, depuis le 19/09/26, au détenteur actif (header
|
||||||
|
* X-Investisseur-Id, ou agrégat si ?scope=all — même mécanisme que /investissements-pe,
|
||||||
|
* /depots-retraits, /remboursements ; correctif d'un bug signalé par Olivier, cette route
|
||||||
|
* ignorait jusqu'ici totalement le détenteur, côté PE comme crowdlending) ;
|
||||||
* - libellé "deal" / nom_deal au lieu de "projet" / nom_projet ;
|
* - libellé "deal" / nom_deal au lieu de "projet" / nom_projet ;
|
||||||
* - gating d'accès via hasFraisPe (cf. AuthContext.jsx) plutôt que hasFrais, cf. FraisPeOnly
|
* - gating d'accès via hasFraisPe (cf. AuthContext.jsx) plutôt que hasFrais, cf. FraisPeOnly
|
||||||
* dans App.jsx.
|
* dans App.jsx.
|
||||||
@@ -80,7 +83,7 @@ export default function FraisPe() {
|
|||||||
try {
|
try {
|
||||||
const scopeParams = activeView === 'all' ? { scope: 'all' } : {};
|
const scopeParams = activeView === 'all' ? { scope: 'all' } : {};
|
||||||
const [f, d] = await Promise.all([
|
const [f, d] = await Promise.all([
|
||||||
api.get('/frais-operations'),
|
api.get('/frais-operations', scopeParams),
|
||||||
api.get('/investissements-pe', scopeParams),
|
api.get('/investissements-pe', scopeParams),
|
||||||
]);
|
]);
|
||||||
setAllFraisOps(f);
|
setAllFraisOps(f);
|
||||||
|
|||||||
@@ -44,6 +44,12 @@ const STATUT_LABELS = {
|
|||||||
perte_definitive: 'Perte définitive',
|
perte_definitive: 'Perte définitive',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const MODE_DETENTION_LABELS = {
|
||||||
|
direct: 'Direct',
|
||||||
|
pea_pme: 'PEA / PEA-PME',
|
||||||
|
assurance_vie: 'Assurance-vie',
|
||||||
|
};
|
||||||
|
|
||||||
const FRAIS_MODE_LABELS = {
|
const FRAIS_MODE_LABELS = {
|
||||||
source: 'À la source',
|
source: 'À la source',
|
||||||
portefeuille: 'Porte-monnaie',
|
portefeuille: 'Porte-monnaie',
|
||||||
@@ -628,6 +634,7 @@ export default function InvestissementPeDetail() {
|
|||||||
{deal.objectif ? ` · Objectif : ${deal.objectif}` : ''}
|
{deal.objectif ? ` · Objectif : ${deal.objectif}` : ''}
|
||||||
{deal.reference ? ` · Réf. ${deal.reference}` : ''}
|
{deal.reference ? ` · Réf. ${deal.reference}` : ''}
|
||||||
{deal.statut !== 'valide' ? ` · ${STATUT_LABELS[deal.statut] || deal.statut}` : ''}
|
{deal.statut !== 'valide' ? ` · ${STATUT_LABELS[deal.statut] || deal.statut}` : ''}
|
||||||
|
{` · ${MODE_DETENTION_LABELS[deal.mode_detention] || 'Direct'}`}
|
||||||
</div>
|
</div>
|
||||||
{deal.notes && <div className="text-muted" style={{ marginBottom: 12, fontSize: 13 }}>{deal.notes}</div>}
|
{deal.notes && <div className="text-muted" style={{ marginBottom: 12, fontSize: 13 }}>{deal.notes}</div>}
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export const emptyDealForm = {
|
|||||||
statut: 'valide',
|
statut: 'valide',
|
||||||
reference: '',
|
reference: '',
|
||||||
notes: '',
|
notes: '',
|
||||||
|
mode_detention: 'direct',
|
||||||
// Étape "Frais" de la modale (chantier 17/09/26, demande Olivier — captures Fundora) : deux
|
// Étape "Frais" de la modale (chantier 17/09/26, demande Olivier — captures Fundora) : deux
|
||||||
// lignes indépendantes journalisées comme deux entrées frais_operations distinctes. Frais
|
// lignes indépendantes journalisées comme deux entrées frais_operations distinctes. Frais
|
||||||
// d'entrée → catégorie 'souscription' (unique par deal) ; Frais de gestion → catégorie
|
// d'entrée → catégorie 'souscription' (unique par deal) ; Frais de gestion → catégorie
|
||||||
@@ -141,7 +142,7 @@ function TrendBadge({ current, prev, invert = false }) {
|
|||||||
* historique à la date de coupure.
|
* historique à la date de coupure.
|
||||||
*/
|
*/
|
||||||
export default function InvestissementsPe() {
|
export default function InvestissementsPe() {
|
||||||
const { activeId: investisseurId, investisseurs } = useInvestisseur();
|
const { activeId: investisseurId, activeView, investisseurs } = useInvestisseur();
|
||||||
const { active: activeWorkspace } = useWorkspace() || {};
|
const { active: activeWorkspace } = useWorkspace() || {};
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -189,10 +190,11 @@ export default function InvestissementsPe() {
|
|||||||
const load = async () => {
|
const load = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
|
const scopeParams = activeView === 'all' ? { scope: 'all' } : {};
|
||||||
const [inv, pl, val] = await Promise.all([
|
const [inv, pl, val] = await Promise.all([
|
||||||
api.get('/investissements-pe'),
|
api.get('/investissements-pe', scopeParams),
|
||||||
api.get('/plateformes'),
|
api.get('/plateformes'),
|
||||||
api.get('/investissements-pe/valorisations'),
|
api.get('/investissements-pe/valorisations', scopeParams),
|
||||||
]);
|
]);
|
||||||
setRows(inv);
|
setRows(inv);
|
||||||
setAllPlats(pl);
|
setAllPlats(pl);
|
||||||
@@ -205,9 +207,10 @@ export default function InvestissementsPe() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (investisseurId) load();
|
if (!investisseurId && activeView !== 'all') return;
|
||||||
|
load();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [investisseurId, activeWorkspace?.id]);
|
}, [investisseurId, activeView, activeWorkspace?.id]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!form.investisseur_id || !modalOpen) { setComptesInvestisseur([]); return; }
|
if (!form.investisseur_id || !modalOpen) { setComptesInvestisseur([]); return; }
|
||||||
@@ -297,6 +300,7 @@ export default function InvestissementsPe() {
|
|||||||
statut: r.statut,
|
statut: r.statut,
|
||||||
reference: r.reference || '',
|
reference: r.reference || '',
|
||||||
notes: r.notes || '',
|
notes: r.notes || '',
|
||||||
|
mode_detention: r.mode_detention || 'direct',
|
||||||
frais_entree_mode_saisie: 'montant', frais_entree_valeur: '', frais_entree_mode_reglement: 'source', frais_entree_compte_id: '', frais_entree_id: null,
|
frais_entree_mode_saisie: 'montant', frais_entree_valeur: '', frais_entree_mode_reglement: 'source', frais_entree_compte_id: '', frais_entree_id: null,
|
||||||
frais_gestion_mode_saisie: 'montant', frais_gestion_valeur: '', frais_gestion_mode_reglement: 'source', frais_gestion_compte_id: '', frais_gestion_id: null,
|
frais_gestion_mode_saisie: 'montant', frais_gestion_valeur: '', frais_gestion_mode_reglement: 'source', frais_gestion_compte_id: '', frais_gestion_id: null,
|
||||||
});
|
});
|
||||||
@@ -424,6 +428,7 @@ export default function InvestissementsPe() {
|
|||||||
statut: form.statut,
|
statut: form.statut,
|
||||||
reference: form.reference || undefined,
|
reference: form.reference || undefined,
|
||||||
notes: form.notes || undefined,
|
notes: form.notes || undefined,
|
||||||
|
mode_detention: form.mode_detention,
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
let dealId = editingId;
|
let dealId = editingId;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useInvestisseur } from '../context/InvestisseurContext.jsx';
|
|||||||
import { useUi } from '../context/UiContext.jsx';
|
import { useUi } from '../context/UiContext.jsx';
|
||||||
import PageIcon from '../components/PageIcon.jsx';
|
import PageIcon from '../components/PageIcon.jsx';
|
||||||
import EmptyState from '../components/EmptyState.jsx';
|
import EmptyState from '../components/EmptyState.jsx';
|
||||||
|
import PlatSelector from '../components/PlatSelector.jsx';
|
||||||
import InvChart from '../components/InvChart.jsx';
|
import InvChart from '../components/InvChart.jsx';
|
||||||
import InvMensuelTable from '../components/InvMensuelTable.jsx';
|
import InvMensuelTable from '../components/InvMensuelTable.jsx';
|
||||||
import SuiviObjectifsCapital from '../components/SuiviObjectifsCapital.jsx';
|
import SuiviObjectifsCapital from '../components/SuiviObjectifsCapital.jsx';
|
||||||
@@ -24,7 +25,6 @@ import {
|
|||||||
timestampSuffix, dlBlob, sortMouvements, computeSoldeIndicatifMap,
|
timestampSuffix, dlBlob, sortMouvements, computeSoldeIndicatifMap,
|
||||||
} from '../components/MouvementsShared.jsx';
|
} from '../components/MouvementsShared.jsx';
|
||||||
|
|
||||||
const LOGOS_BASE = '/api/logos/';
|
|
||||||
|
|
||||||
/* ── TrendBadge ── */
|
/* ── TrendBadge ── */
|
||||||
function TrendBadge({ current, prev, invert = false }) {
|
function TrendBadge({ current, prev, invert = false }) {
|
||||||
@@ -53,94 +53,6 @@ function TrendBadge({ current, prev, invert = false }) {
|
|||||||
|
|
||||||
|
|
||||||
/* ── Sélecteur Plateforme ── */
|
/* ── Sélecteur Plateforme ── */
|
||||||
function PlatSelector({ platOptions, selectedPlatName, onSelect }) {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const ref = useRef(null);
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
const h = e => { if (!ref.current?.contains(e.target)) setOpen(false); };
|
|
||||||
document.addEventListener('mousedown', h);
|
|
||||||
return () => document.removeEventListener('mousedown', h);
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
const selected = platOptions.find(p => p.nom === selectedPlatName) || platOptions[0];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={ref} style={{ position: 'relative' }}>
|
|
||||||
<div
|
|
||||||
onClick={() => setOpen(v => !v)}
|
|
||||||
style={{
|
|
||||||
background: 'linear-gradient(135deg, var(--primary) 0%, var(--primary-hover) 100%)',
|
|
||||||
borderRadius: 10, padding: '12px 16px',
|
|
||||||
boxShadow: open
|
|
||||||
? '0 6px 28px color-mix(in srgb, var(--primary) 45%, transparent)'
|
|
||||||
: '0 4px 20px color-mix(in srgb, var(--primary) 30%, transparent)',
|
|
||||||
cursor: 'pointer', userSelect: 'none', transition: 'box-shadow .15s',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 }}>
|
|
||||||
<span style={{ fontSize: 'var(--fs-xs)', textTransform: 'uppercase', letterSpacing: '.06em', color: 'rgba(255,255,255,0.7)', fontWeight: 500 }}>
|
|
||||||
Plateforme
|
|
||||||
</span>
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
|
|
||||||
stroke="rgba(255,255,255,0.7)" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"
|
|
||||||
style={{ transform: open ? 'rotate(180deg)' : 'none', transition: 'transform .2s', flexShrink: 0 }}>
|
|
||||||
<polyline points="6 9 12 15 18 9"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
|
||||||
{selected?.icon_filename && (
|
|
||||||
<img src={`${LOGOS_BASE}${selected.icon_filename}`}
|
|
||||||
width={28} height={28}
|
|
||||||
style={{ objectFit: 'contain', borderRadius: 4, background: '#fff', padding: 2, flexShrink: 0 }}
|
|
||||||
alt="" />
|
|
||||||
)}
|
|
||||||
<span style={{ color: '#fff', fontSize: '1.1rem', fontWeight: 700, lineHeight: 1.2 }}>
|
|
||||||
{selected?.nom || '—'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{open && (
|
|
||||||
<div style={{
|
|
||||||
position: 'absolute', top: 'calc(100% + 6px)', left: 0, right: 0, zIndex: 200,
|
|
||||||
background: 'var(--surface)', border: '1px solid var(--border)',
|
|
||||||
borderRadius: 10, boxShadow: '0 8px 28px rgba(0,0,0,0.15)', overflow: 'hidden',
|
|
||||||
maxHeight: 280, overflowY: 'auto',
|
|
||||||
}}>
|
|
||||||
{platOptions.map(p => (
|
|
||||||
<div
|
|
||||||
key={p.nom}
|
|
||||||
onClick={() => { onSelect(p.nom); setOpen(false); }}
|
|
||||||
style={{
|
|
||||||
display: 'flex', alignItems: 'center', gap: 10,
|
|
||||||
padding: '10px 14px', cursor: 'pointer',
|
|
||||||
background: p.nom === selectedPlatName ? 'var(--surface-2)' : 'transparent',
|
|
||||||
fontWeight: p.nom === selectedPlatName ? 700 : 400,
|
|
||||||
fontSize: 'var(--fs-sm)',
|
|
||||||
borderBottom: '1px solid var(--border)',
|
|
||||||
transition: 'background .1s',
|
|
||||||
}}
|
|
||||||
onMouseEnter={e => { if (p.nom !== selectedPlatName) e.currentTarget.style.background = 'var(--surface-2)'; }}
|
|
||||||
onMouseLeave={e => { if (p.nom !== selectedPlatName) e.currentTarget.style.background = 'transparent'; }}
|
|
||||||
>
|
|
||||||
{p.icon_filename ? (
|
|
||||||
<img src={`${LOGOS_BASE}${p.icon_filename}`}
|
|
||||||
width={20} height={20}
|
|
||||||
style={{ objectFit: 'contain', flexShrink: 0, opacity: 0.8 }}
|
|
||||||
alt="" />
|
|
||||||
) : (
|
|
||||||
<span style={{ width: 20, height: 20, borderRadius: 4, background: 'var(--surface-2)', flexShrink: 0, display: 'inline-block' }} />
|
|
||||||
)}
|
|
||||||
<span>{p.nom}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Sélecteur Détenteur ── */
|
/* ── Sélecteur Détenteur ── */
|
||||||
function DetenteurSelector({ detenteurOptions, selectedId, onSelect }) {
|
function DetenteurSelector({ detenteurOptions, selectedId, onSelect }) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|||||||
@@ -0,0 +1,646 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
|
import { api } from '../api.js';
|
||||||
|
import { useInvestisseur } from '../context/InvestisseurContext.jsx';
|
||||||
|
import { useWorkspace } from '../context/WorkspaceContext.jsx';
|
||||||
|
import Modal from '../components/Modal.jsx';
|
||||||
|
import ConfirmModal from '../components/ConfirmModal.jsx';
|
||||||
|
import EmptyState from '../components/EmptyState.jsx';
|
||||||
|
import PageIcon from '../components/PageIcon.jsx';
|
||||||
|
import { useAsyncAction } from '../hooks/useAsyncAction.js';
|
||||||
|
import { fmtEUR, fmtDate } from '../utils/format.js';
|
||||||
|
import {
|
||||||
|
sortMouvements, computeSoldeIndicatifMap, signedMontant, MOUV_TYPE_LABELS,
|
||||||
|
ExportDropdown, mouvToCSV, mouvToXLS, mouvToJSON, timestampSuffix, dlBlob,
|
||||||
|
} from '../components/MouvementsShared.jsx';
|
||||||
|
import PlatSelector from '../components/PlatSelector.jsx';
|
||||||
|
import InvChart from '../components/InvChart.jsx';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Page Plateformes — Private Equity (19/09/26, demande Olivier : "Nouvelle page dédiée mais
|
||||||
|
* similaire dans la présentation graphique [à Plateformes.jsx crowdlending] avec le maximum
|
||||||
|
* d'onglet réplicable. Il me faut aussi les mouvements du porte-monnaie").
|
||||||
|
*
|
||||||
|
* Contrairement à Plateformes.jsx (3362 lignes, très couplé au modèle crowdlending —
|
||||||
|
* échéancier, "Mois en cours", "Capital investi" amortissable), cette page est reconstruite
|
||||||
|
* plus sobrement autour des mêmes briques visuelles (kpi, dr-tabs, PlatSelector) mais avec
|
||||||
|
* uniquement les onglets qui ont un sens pour un deal PE : Investissements, Distributions,
|
||||||
|
* Dépôts/Retraits, Mouvements Porte-monnaie. Les onglets "Mois en cours" et "Capital investi"
|
||||||
|
* (échéancier/amortissement) n'existent pas côté PE (cf. project_workspaces_transformation.md)
|
||||||
|
* et ne sont donc pas répliqués.
|
||||||
|
*
|
||||||
|
* Onglet "Mouvements Porte-monnaie" : réutilise GET /investissements-pe/mouvements-portefeuille
|
||||||
|
* (déjà utilisé en sens inverse par Plateformes.jsx pour faire remonter les mouvements PE côté
|
||||||
|
* crowdlending) avec le nouveau paramètre ?native=1 (19/09/26) qui bascule le filtre
|
||||||
|
* depots_retraits sur le workspace PE actif au lieu de l'exclure. Limite connue, signalée à
|
||||||
|
* Olivier : si une plateforme est un jour partagée entre crowdlending ET Private Equity, cette
|
||||||
|
* page ne remonte pas encore les mouvements côté crowdlending de cette plateforme (l'inverse
|
||||||
|
* existe déjà côté Plateformes.jsx) — non bloquant aujourd'hui, ses plateformes PE (Fundora,
|
||||||
|
* Baltis) sont dédiées.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const STATUT_LABELS = {
|
||||||
|
en_attente: 'En attente',
|
||||||
|
valide: 'Validé',
|
||||||
|
cloture: 'Clôturé',
|
||||||
|
perte_definitive: 'Perte définitive',
|
||||||
|
};
|
||||||
|
const MODE_DETENTION_LABELS = {
|
||||||
|
direct: 'Direct',
|
||||||
|
pea_pme: 'PEA / PEA-PME',
|
||||||
|
assurance_vie: 'Assurance-vie',
|
||||||
|
};
|
||||||
|
const fmtMultiple = (n) => (n != null ? `x${n.toFixed(2).replace('.', ',')}` : '—');
|
||||||
|
|
||||||
|
/* ── Sélecteur Période (copié de Plateformes.jsx — alimente InvChart via platYear, même
|
||||||
|
principe que le sélecteur Année du graphique "Capital investi" crowdlending) ── */
|
||||||
|
function AnneeSelector({ availableYears, selectedYear, onSelect }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const ref = useRef(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const h = e => { if (!ref.current?.contains(e.target)) setOpen(false); };
|
||||||
|
document.addEventListener('mousedown', h);
|
||||||
|
return () => document.removeEventListener('mousedown', h);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const options = [
|
||||||
|
{ value: '', label: 'Depuis le début' },
|
||||||
|
...[...availableYears].sort((a, b) => b - a).map(y => ({ value: String(y), label: String(y) })),
|
||||||
|
];
|
||||||
|
const displayLabel = selectedYear || 'Depuis le début';
|
||||||
|
const isGlobal = !selectedYear;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} style={{ position: 'relative' }}>
|
||||||
|
<div
|
||||||
|
onClick={() => setOpen(v => !v)}
|
||||||
|
style={{
|
||||||
|
background: 'linear-gradient(135deg, var(--primary) 0%, var(--primary-hover) 100%)',
|
||||||
|
borderRadius: 10, padding: '12px 16px',
|
||||||
|
boxShadow: open
|
||||||
|
? '0 6px 28px color-mix(in srgb, var(--primary) 45%, transparent)'
|
||||||
|
: '0 4px 20px color-mix(in srgb, var(--primary) 30%, transparent)',
|
||||||
|
cursor: 'pointer', userSelect: 'none', transition: 'box-shadow .15s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 }}>
|
||||||
|
<span style={{ fontSize: 'var(--fs-xs)', textTransform: 'uppercase', letterSpacing: '.06em', color: 'rgba(255,255,255,0.7)', fontWeight: 500 }}>
|
||||||
|
Période
|
||||||
|
</span>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
|
||||||
|
stroke="rgba(255,255,255,0.7)" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"
|
||||||
|
style={{ transform: open ? 'rotate(180deg)' : 'none', transition: 'transform .2s', flexShrink: 0 }}>
|
||||||
|
<polyline points="6 9 12 15 18 9"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span style={{ color: '#fff', fontSize: isGlobal ? '1rem' : '1.8rem', fontWeight: 700, lineHeight: 1.1 }}>
|
||||||
|
{displayLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', top: 'calc(100% + 6px)', left: 0, right: 0, zIndex: 200,
|
||||||
|
background: 'var(--surface)', border: '1px solid var(--border)',
|
||||||
|
borderRadius: 10, boxShadow: '0 8px 28px rgba(0,0,0,0.15)', overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
{options.map(opt => (
|
||||||
|
<div
|
||||||
|
key={opt.value}
|
||||||
|
onClick={() => { onSelect(opt.value); setOpen(false); }}
|
||||||
|
style={{
|
||||||
|
padding: '10px 14px', cursor: 'pointer',
|
||||||
|
background: opt.value === selectedYear ? 'var(--surface-2)' : 'transparent',
|
||||||
|
fontWeight: opt.value === selectedYear ? 700 : 400,
|
||||||
|
fontSize: 'var(--fs-sm)',
|
||||||
|
borderBottom: '1px solid var(--border)',
|
||||||
|
transition: 'background .1s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { if (opt.value !== selectedYear) e.currentTarget.style.background = 'var(--surface-2)'; }}
|
||||||
|
onMouseLeave={e => { if (opt.value !== selectedYear) e.currentTarget.style.background = 'transparent'; }}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PlateformesPe() {
|
||||||
|
const { activeId, activeView, investisseurs } = useInvestisseur();
|
||||||
|
const { active: activeWorkspace } = useWorkspace() || {};
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
||||||
|
const [allPlats, setAllPlats] = useState([]);
|
||||||
|
const [deals, setDeals] = useState([]);
|
||||||
|
const [depots, setDepots] = useState([]);
|
||||||
|
const [remboursements, setRemboursements] = useState([]);
|
||||||
|
const [walletNative, setWalletNative] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [err, setErr] = useState(null);
|
||||||
|
const [selectedYear, setSelectedYear] = useState(''); // Période du graphique Capital investi
|
||||||
|
|
||||||
|
const selectedPlatName = searchParams.get('plat') || null;
|
||||||
|
const setSelectedPlatName = (nom) => {
|
||||||
|
setSearchParams(p => { const n = new URLSearchParams(p); n.set('plat', nom); return n; }, { replace: true });
|
||||||
|
};
|
||||||
|
const activeTab = searchParams.get('tab') || 'investissements';
|
||||||
|
const setActiveTab = (tab) => {
|
||||||
|
setSearchParams(p => { const n = new URLSearchParams(p); n.set('tab', tab); return n; }, { replace: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
const multiDetenteur = new Set(allPlats.map(p => p.investisseur_id)).size > 1;
|
||||||
|
|
||||||
|
// Plateformes affectées au workspace PE actif — même filtre que InvestissementsPe.jsx
|
||||||
|
// (p.workspaces posé par attachWorkspaces() côté backend, GET /plateformes n'est pas
|
||||||
|
// filtré serveur : la liste couvre volontairement tous les workspaces, cf. project_
|
||||||
|
// referentiel_plateformes.md).
|
||||||
|
// Plateformes affectées au workspace PE actif — indépendamment du détenteur, sert
|
||||||
|
// uniquement à distinguer "aucune plateforme configurée" de "aucun deal pour ce détenteur"
|
||||||
|
// dans les EmptyState ci-dessous (même distinction que Plateformes.jsx crowdlending, cf.
|
||||||
|
// plats vs platOptions).
|
||||||
|
const platsWorkspace = useMemo(
|
||||||
|
() => allPlats.filter(p => (p.workspaces || []).some(w => w.id === activeWorkspace?.id)),
|
||||||
|
[allPlats, activeWorkspace],
|
||||||
|
);
|
||||||
|
// Restreint en plus au détenteur courant (19/09/26, bug signalé par Olivier : "les pages
|
||||||
|
// plateforme et frais ne semblent pas filtrer par détenteur" — `deals` est déjà filtré par
|
||||||
|
// détenteur/scope=all via `load()` ci-dessous, mais la liste des plateformes elle-même était
|
||||||
|
// construite directement sur /plateformes, jamais restreinte au détenteur choisi, contrairement
|
||||||
|
// à Plateformes.jsx crowdlending dont platOptions est dérivé des lignes d'investissement déjà
|
||||||
|
// scopées — même principe repris ici pour rester cohérent avec le sélecteur crowdlending).
|
||||||
|
const plats = useMemo(() => {
|
||||||
|
const dealPlatIds = new Set(deals.map(d => d.plateforme_id));
|
||||||
|
return platsWorkspace.filter(p => dealPlatIds.has(p.id));
|
||||||
|
}, [platsWorkspace, deals]);
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
if (!activeId && activeView !== 'all') return;
|
||||||
|
setLoading(true);
|
||||||
|
setErr(null);
|
||||||
|
try {
|
||||||
|
const scopeParams = activeView === 'all' ? { scope: 'all' } : {};
|
||||||
|
const [p, d, dr, remb, wallet] = await Promise.all([
|
||||||
|
api.get('/plateformes'),
|
||||||
|
api.get('/investissements-pe', scopeParams),
|
||||||
|
api.get('/depots-retraits', scopeParams),
|
||||||
|
api.get('/remboursements', { ...scopeParams, type: 'interets_plateforme' }),
|
||||||
|
api.get('/investissements-pe/mouvements-portefeuille', { ...scopeParams, native: 1 }).catch(() => []),
|
||||||
|
]);
|
||||||
|
setAllPlats(p);
|
||||||
|
setDeals(d);
|
||||||
|
setDepots(dr);
|
||||||
|
setRemboursements(remb);
|
||||||
|
setWalletNative(Array.isArray(wallet) ? wallet : []);
|
||||||
|
} catch (e) { setErr(e.message); } finally { setLoading(false); }
|
||||||
|
};
|
||||||
|
useEffect(() => { load(); /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, [activeId, activeView, activeWorkspace?.id]);
|
||||||
|
|
||||||
|
// Sélection automatique de la première plateforme dès que la liste est connue.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedPlatName && plats.length > 0) setSelectedPlatName(plats[0].nom);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [plats.length]);
|
||||||
|
|
||||||
|
const selectedPlat = plats.find(p => p.nom === selectedPlatName) || plats[0];
|
||||||
|
|
||||||
|
const platDeals = useMemo(
|
||||||
|
() => selectedPlat ? deals.filter(d => d.plateforme_id === selectedPlat.id) : [],
|
||||||
|
[deals, selectedPlat],
|
||||||
|
);
|
||||||
|
const platDealIds = useMemo(() => new Set(platDeals.map(d => d.id)), [platDeals]);
|
||||||
|
const platDepots = useMemo(
|
||||||
|
() => selectedPlat ? depots.filter(r => r.plateforme_id === selectedPlat.id) : [],
|
||||||
|
[depots, selectedPlat],
|
||||||
|
);
|
||||||
|
const platRembs = useMemo(
|
||||||
|
() => platDeals.length ? remboursements.filter(r => platDealIds.has(r.investissement_pe_id)) : [],
|
||||||
|
[remboursements, platDealIds, platDeals.length],
|
||||||
|
);
|
||||||
|
// Alias investissement_pe_id → investissement_id : InvChart.jsx (composant partagé avec le
|
||||||
|
// crowdlending, cf. project_workspaces_transformation.md) est déjà générique sur les champs
|
||||||
|
// communs aux deux modèles (id / date_souscription / montant_investi identiques dans
|
||||||
|
// investissements et investissements_pe) mais attend investissement_id pour rattacher un
|
||||||
|
// remboursement à sa ligne — un simple alias plutôt qu'une fourche du composant partagé.
|
||||||
|
const platRembsForChart = useMemo(
|
||||||
|
() => platRembs.map(r => ({ ...r, investissement_id: r.investissement_pe_id })),
|
||||||
|
[platRembs],
|
||||||
|
);
|
||||||
|
// Années disponibles pour le sélecteur "Période" du graphique — même principe que
|
||||||
|
// Plateformes.jsx (années de date_souscription des lignes du scope courant).
|
||||||
|
const availableYears = useMemo(() => {
|
||||||
|
const set = new Set(platDeals.map(d => d.date_souscription?.slice(0, 4)).filter(Boolean));
|
||||||
|
return [...set].map(Number).sort((a, b) => b - a);
|
||||||
|
}, [platDeals]);
|
||||||
|
const platWallet = useMemo(
|
||||||
|
() => selectedPlat ? walletNative.filter(r => r.plateforme_id === selectedPlat.id).map(r => ({ ...r, _investType: 'pe' })) : [],
|
||||||
|
[walletNative, selectedPlat],
|
||||||
|
);
|
||||||
|
|
||||||
|
// KPIs de la plateforme sélectionnée — mêmes formules que le bloc "totals" de
|
||||||
|
// DashboardPe.jsx / backend/src/routes/v1/dashboard.js (bloc investissements_pe),
|
||||||
|
// appliquées ici aux seuls deals de cette plateforme plutôt qu'à tout le portefeuille.
|
||||||
|
const kpis = useMemo(() => {
|
||||||
|
const investi = platDeals.reduce((s, d) => s + (d.montant_investi || 0), 0);
|
||||||
|
const nav = platDeals.reduce((s, d) => s + (d.derniere_valorisation ?? d.montant_investi ?? 0), 0);
|
||||||
|
const distributions = platDeals.reduce((s, d) => s + (d.distributions_cumulees || 0), 0);
|
||||||
|
const tvpi = investi > 0 ? Math.round(((nav + distributions) / investi) * 100) / 100 : null;
|
||||||
|
return { investi, nav, distributions, tvpi };
|
||||||
|
}, [platDeals]);
|
||||||
|
|
||||||
|
const walletSorted = useMemo(() => sortMouvements(platWallet), [platWallet]);
|
||||||
|
const soldeById = useMemo(() => computeSoldeIndicatifMap(platWallet), [platWallet]);
|
||||||
|
const soldeIndicatif = walletSorted.length ? (soldeById.get(walletSorted[0].id) ?? 0) : 0;
|
||||||
|
|
||||||
|
/* ── Modale Dépôt/Retrait (même Schema que backend/src/routes/depotsRetraits.js) ── */
|
||||||
|
const emptyDrForm = { investisseur_id: '', plateforme_id: '', date_operation: '', type: 'depot', montant: '', libelle: '', reference: '', notes: '' };
|
||||||
|
const [drModalOpen, setDrModalOpen] = useState(false);
|
||||||
|
const [drEditingId, setDrEditingId] = useState(null);
|
||||||
|
const [drForm, setDrForm] = useState(emptyDrForm);
|
||||||
|
const [drErr, setDrErr] = useState(null);
|
||||||
|
const [drDeleteTarget, setDrDeleteTarget] = useState(null);
|
||||||
|
const { run: runDrSubmit, pending: drSubmitting } = useAsyncAction();
|
||||||
|
|
||||||
|
const today = () => new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
const openDrAdd = () => {
|
||||||
|
setDrEditingId(null);
|
||||||
|
setDrForm({ ...emptyDrForm, plateforme_id: selectedPlat?.id || '', investisseur_id: selectedPlat?.investisseur_id ? String(selectedPlat.investisseur_id) : '', date_operation: today() });
|
||||||
|
setDrErr(null);
|
||||||
|
setDrModalOpen(true);
|
||||||
|
};
|
||||||
|
const openDrEdit = (r) => {
|
||||||
|
setDrEditingId(r.id);
|
||||||
|
setDrForm({
|
||||||
|
investisseur_id: String(r.investisseur_id || ''),
|
||||||
|
plateforme_id: String(r.plateforme_id || ''),
|
||||||
|
date_operation: r.date_operation,
|
||||||
|
type: r.type,
|
||||||
|
montant: String(r.montant ?? ''),
|
||||||
|
libelle: r.libelle || '',
|
||||||
|
reference: r.reference || '',
|
||||||
|
notes: r.notes || '',
|
||||||
|
});
|
||||||
|
setDrErr(null);
|
||||||
|
setDrModalOpen(true);
|
||||||
|
};
|
||||||
|
const closeDr = () => { setDrModalOpen(false); setDrEditingId(null); setDrForm(emptyDrForm); setDrErr(null); };
|
||||||
|
const submitDr = async (e) => {
|
||||||
|
e?.preventDefault?.(); setDrErr(null);
|
||||||
|
const payload = {
|
||||||
|
investisseur_id: Number(drForm.investisseur_id) || undefined,
|
||||||
|
plateforme_id: Number(drForm.plateforme_id),
|
||||||
|
date_operation: drForm.date_operation,
|
||||||
|
type: drForm.type,
|
||||||
|
montant: Number(drForm.montant),
|
||||||
|
libelle: drForm.libelle || undefined,
|
||||||
|
reference: drForm.reference || undefined,
|
||||||
|
notes: drForm.notes || undefined,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
if (drEditingId) await api.put(`/depots-retraits/${drEditingId}`, payload);
|
||||||
|
else await api.post('/depots-retraits', payload);
|
||||||
|
closeDr();
|
||||||
|
await load();
|
||||||
|
} catch (e) { setDrErr(e.message); }
|
||||||
|
};
|
||||||
|
const onDeleteDr = async () => {
|
||||||
|
if (!drDeleteTarget) return;
|
||||||
|
try {
|
||||||
|
await api.del(`/depots-retraits/${drDeleteTarget.id}`);
|
||||||
|
setDrDeleteTarget(null);
|
||||||
|
await load();
|
||||||
|
} catch (e) { setErr(e.message); }
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ── Export de l'onglet Mouvements Porte-monnaie ── */
|
||||||
|
const exportWallet = (format) => {
|
||||||
|
const rows = walletSorted.map(r => ({
|
||||||
|
...r,
|
||||||
|
solde_indicatif: soldeById.get(r.id),
|
||||||
|
type_label: MOUV_TYPE_LABELS[r.type] || r.type,
|
||||||
|
}));
|
||||||
|
const suffix = timestampSuffix();
|
||||||
|
if (format === 'csv') dlBlob(mouvToCSV(rows), `mouvements_${selectedPlat?.nom || 'pe'}_${suffix}.csv`, 'text/csv;charset=utf-8;');
|
||||||
|
else if (format === 'xls') dlBlob(mouvToXLS(rows, multiDetenteur), `mouvements_${selectedPlat?.nom || 'pe'}_${suffix}.xls`, 'application/vnd.ms-excel');
|
||||||
|
else dlBlob(mouvToJSON(rows), `mouvements_${selectedPlat?.nom || 'pe'}_${suffix}.json`, 'application/json');
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!loading && platsWorkspace.length === 0) return (
|
||||||
|
<>
|
||||||
|
<div className="topbar"><h2><PageIcon name="plateforme" />Plateformes</h2></div>
|
||||||
|
<EmptyState
|
||||||
|
title="Aucune plateforme dans cet espace de travail"
|
||||||
|
message={`Ajoutez d'abord une plateforme à ${activeWorkspace?.libelle_menu || activeWorkspace?.nom || 'ce workspace'} depuis Mes plateformes.`}
|
||||||
|
cta="Configurer mes plateformes"
|
||||||
|
to="/settings?section=plateformes&openAdd=1"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!loading && plats.length === 0) return (
|
||||||
|
<>
|
||||||
|
<div className="topbar"><h2><PageIcon name="plateforme" />Plateformes</h2></div>
|
||||||
|
<EmptyState
|
||||||
|
icon="📊"
|
||||||
|
title="Aucun deal pour le moment"
|
||||||
|
message="Vos plateformes sont configurées, mais ce détenteur n'a encore aucun deal enregistré dessus. Ajoutez un investissement, ou changez de détenteur (menu en haut à droite), pour voir apparaître les données."
|
||||||
|
cta="Ajouter un investissement"
|
||||||
|
to="/investissements?new=1"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="topbar">
|
||||||
|
<h2><PageIcon name="plateforme" />Plateformes</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{err && <div className="error" style={{ margin: '0 0 16px' }}>{err}</div>}
|
||||||
|
|
||||||
|
{selectedPlat && (
|
||||||
|
<>
|
||||||
|
{/* ── Graphique + sélecteurs (19/09/26, demande Olivier : "graph sur le capital
|
||||||
|
investi" — même format que le graphique Capital investi de Plateformes.jsx
|
||||||
|
crowdlending, InvChart.jsx réutilisé tel quel cf. commentaire platRembsForChart
|
||||||
|
ci-dessus) ── */}
|
||||||
|
<div className="charts-row">
|
||||||
|
<InvChart
|
||||||
|
rows={platDeals}
|
||||||
|
remboursements={platRembsForChart}
|
||||||
|
reinvestissements={[]}
|
||||||
|
platYear={selectedYear}
|
||||||
|
/>
|
||||||
|
<div className="dist-chart-wrap" style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
|
<PlatSelector
|
||||||
|
platOptions={plats}
|
||||||
|
selectedPlatName={selectedPlat?.nom}
|
||||||
|
onSelect={setSelectedPlatName}
|
||||||
|
/>
|
||||||
|
<AnneeSelector
|
||||||
|
availableYears={availableYears}
|
||||||
|
selectedYear={selectedYear}
|
||||||
|
onSelect={setSelectedYear}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── KPIs ── */}
|
||||||
|
<div className="dr-kpi-row">
|
||||||
|
<div className="kpi">
|
||||||
|
<div className="label">Capital investi</div>
|
||||||
|
<div style={{ fontSize: '1.35rem', fontWeight: 700, marginTop: 4 }}>{fmtEUR(kpis.investi)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="kpi">
|
||||||
|
<div className="label">Valorisation nette (NAV)</div>
|
||||||
|
<div style={{ fontSize: '1.35rem', fontWeight: 700, marginTop: 4 }}>{fmtEUR(kpis.nav)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="kpi">
|
||||||
|
<div className="label">Distributions cumulées</div>
|
||||||
|
<div style={{ fontSize: '1.35rem', fontWeight: 700, marginTop: 4 }}>{fmtEUR(kpis.distributions)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="kpi" title="TVPI = (NAV + distributions cumulées) / capital investi">
|
||||||
|
<div className="label">TVPI</div>
|
||||||
|
<div style={{ fontSize: '1.35rem', fontWeight: 700, marginTop: 4 }}>{fmtMultiple(kpis.tvpi)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Onglets ── */}
|
||||||
|
<div className="dr-tabs">
|
||||||
|
<button className={`dr-tab${activeTab === 'investissements' ? ' active' : ''}`}
|
||||||
|
onClick={() => setActiveTab('investissements')}>Investissements</button>
|
||||||
|
<button className={`dr-tab${activeTab === 'distributions' ? ' active' : ''}`}
|
||||||
|
onClick={() => setActiveTab('distributions')}>Distributions</button>
|
||||||
|
<button className={`dr-tab${activeTab === 'depots-retraits' ? ' active' : ''}`}
|
||||||
|
onClick={() => setActiveTab('depots-retraits')}>Dépôts / Retraits</button>
|
||||||
|
<button className={`dr-tab${activeTab === 'mouvements-pm' ? ' active' : ''}`}
|
||||||
|
onClick={() => setActiveTab('mouvements-pm')}>Mouvements Porte-monnaie</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ====== ONGLET INVESTISSEMENTS ====== */}
|
||||||
|
{activeTab === 'investissements' && (
|
||||||
|
<div className="card">
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 14 }}>
|
||||||
|
<h3 style={{ margin: 0 }}>Investissements sur {selectedPlat.nom}</h3>
|
||||||
|
<button className="primary" onClick={() => navigate('/investissements?new=1')}>+ Nouveau deal</button>
|
||||||
|
</div>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Deal</th><th>Souscrit le</th><th className="num">Montant investi</th>
|
||||||
|
<th className="num">Dernière valorisation</th><th className="num">TVPI</th>
|
||||||
|
<th>Statut</th><th>Détention</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{platDeals.length === 0 && (
|
||||||
|
<tr><td colSpan={7} className="text-muted" style={{ textAlign: 'center' }}>Aucun deal sur cette plateforme</td></tr>
|
||||||
|
)}
|
||||||
|
{platDeals.map(d => (
|
||||||
|
<tr key={d.id} className="dr-row" style={{ cursor: 'pointer' }}
|
||||||
|
onClick={() => navigate(`/investissements/${d.id}`)}>
|
||||||
|
<td>{d.nom_deal}</td>
|
||||||
|
<td>{fmtDate(d.date_souscription)}</td>
|
||||||
|
<td className="num">{fmtEUR(d.montant_investi)}</td>
|
||||||
|
<td className="num">{d.derniere_valorisation != null ? fmtEUR(d.derniere_valorisation) : '—'}</td>
|
||||||
|
<td className="num">{fmtMultiple(d.tvpi)}</td>
|
||||||
|
<td>{STATUT_LABELS[d.statut] || d.statut}</td>
|
||||||
|
<td className="text-muted">{MODE_DETENTION_LABELS[d.mode_detention] || 'Direct'}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ====== ONGLET DISTRIBUTIONS ====== */}
|
||||||
|
{activeTab === 'distributions' && (
|
||||||
|
<div className="card">
|
||||||
|
<h3 style={{ margin: '0 0 14px' }}>Distributions perçues sur {selectedPlat.nom}</h3>
|
||||||
|
<p className="text-muted" style={{ fontSize: 13, marginTop: 0 }}>
|
||||||
|
Les distributions se saisissent depuis la fiche de chaque deal, page Investissements
|
||||||
|
(comme un remboursement) — cette vue est un récapitulatif en lecture seule.
|
||||||
|
</p>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Date</th><th>Deal</th><th>Nature</th><th className="num">Montant net perçu</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{platRembs.length === 0 && (
|
||||||
|
<tr><td colSpan={4} className="text-muted" style={{ textAlign: 'center' }}>Aucune distribution enregistrée</td></tr>
|
||||||
|
)}
|
||||||
|
{[...platRembs].sort((a, b) => b.date_remb.localeCompare(a.date_remb) || b.id - a.id).map(r => (
|
||||||
|
<tr key={r.id}>
|
||||||
|
<td>{fmtDate(r.date_remb)}</td>
|
||||||
|
<td>{deals.find(d => d.id === r.investissement_pe_id)?.nom_deal || '—'}</td>
|
||||||
|
<td className="text-muted">{r.distribution_nature === 'interet_non_deploye' ? 'Intérêt sur capital non déployé' : 'Restitution de capital'}</td>
|
||||||
|
<td className="num">{fmtEUR(r.net_recu)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ====== ONGLET DÉPÔTS / RETRAITS ====== */}
|
||||||
|
{activeTab === 'depots-retraits' && (
|
||||||
|
<div className="card">
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 14 }}>
|
||||||
|
<h3 style={{ margin: 0 }}>Dépôts / Retraits — {selectedPlat.nom}</h3>
|
||||||
|
<button className="primary" onClick={openDrAdd}>+ Nouveau dépôt/retrait</button>
|
||||||
|
</div>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Date</th><th>Type</th><th className="num">Montant</th><th>Libellé</th><th style={{ width: 40 }}></th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{platDepots.length === 0 && (
|
||||||
|
<tr><td colSpan={5} className="text-muted" style={{ textAlign: 'center' }}>Aucun mouvement</td></tr>
|
||||||
|
)}
|
||||||
|
{[...platDepots].sort((a, b) => b.date_operation.localeCompare(a.date_operation) || b.id - a.id).map(r => (
|
||||||
|
<tr key={r.id} className="dr-row" style={{ cursor: 'pointer' }} onClick={() => openDrEdit(r)}>
|
||||||
|
<td>{fmtDate(r.date_operation)}</td>
|
||||||
|
<td>{r.type === 'depot' ? 'Dépôt' : 'Retrait'}</td>
|
||||||
|
<td className="num">{fmtEUR(r.montant)}</td>
|
||||||
|
<td className="text-muted">{r.libelle || '—'}</td>
|
||||||
|
<td onClick={e => e.stopPropagation()}>
|
||||||
|
<button className="icon-btn" title="Supprimer" onClick={() => setDrDeleteTarget(r)}>✕</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ====== ONGLET MOUVEMENTS PORTE-MONNAIE ====== */}
|
||||||
|
{activeTab === 'mouvements-pm' && (
|
||||||
|
<div className="card">
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 14 }}>
|
||||||
|
<h3 style={{ margin: 0 }}>Mouvements Porte-monnaie — {selectedPlat.nom}</h3>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
|
||||||
|
<span className="text-muted" style={{ fontSize: 13 }}>Solde indicatif : <strong>{fmtEUR(soldeIndicatif)}</strong></span>
|
||||||
|
<ExportDropdown disabled={walletSorted.length === 0}
|
||||||
|
onCSV={() => exportWallet('csv')} onXLS={() => exportWallet('xls')} onJSON={() => exportWallet('json')} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted" style={{ fontSize: 13, marginTop: 0 }}>
|
||||||
|
Journal unifié des souscriptions, distributions, frais et dépôts/retraits de cette
|
||||||
|
plateforme — solde indicatif, pas une source de vérité chiffrée (mêmes principes que
|
||||||
|
l'onglet équivalent côté crowdlending). Chaque ligne s'édite depuis son onglet d'origine.
|
||||||
|
</p>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Date</th><th>Type</th><th>Libellé</th><th className="num">Montant</th><th className="num">Solde indicatif</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{walletSorted.length === 0 && (
|
||||||
|
<tr><td colSpan={5} className="text-muted" style={{ textAlign: 'center' }}>Aucun mouvement</td></tr>
|
||||||
|
)}
|
||||||
|
{walletSorted.map(r => {
|
||||||
|
// signedMontant() (MouvementsShared.jsx) reste la seule source de vérité pour ce
|
||||||
|
// calcul, partagée avec l'onglet équivalent côté crowdlending — ne jamais le
|
||||||
|
// réimplémenter ici, cf. project_mouvements_shared_plateformes_tab.md.
|
||||||
|
const signed = signedMontant(r);
|
||||||
|
return (
|
||||||
|
<tr key={r.id}>
|
||||||
|
<td>{fmtDate(r.date_operation)}</td>
|
||||||
|
<td>{MOUV_TYPE_LABELS[r.type] || r.type}</td>
|
||||||
|
<td className="text-muted">{r.libelle || '—'}</td>
|
||||||
|
<td className="num" style={{ color: signed < 0 ? 'var(--danger, #dc2626)' : signed > 0 ? 'var(--success, #16a34a)' : undefined }}>
|
||||||
|
{signed > 0 ? '+' : ''}{fmtEUR(signed)}
|
||||||
|
</td>
|
||||||
|
<td className="num text-muted">{fmtEUR(soldeById.get(r.id) ?? 0)}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={drModalOpen}
|
||||||
|
title={drEditingId ? 'Modifier le mouvement' : 'Nouveau dépôt/retrait'}
|
||||||
|
onClose={closeDr}
|
||||||
|
busy={drSubmitting}
|
||||||
|
footer={
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', width: '100%' }}>
|
||||||
|
<div>
|
||||||
|
{drEditingId && (
|
||||||
|
<button className="danger" disabled={drSubmitting}
|
||||||
|
onClick={() => { const target = { id: drEditingId }; closeDr(); setDrDeleteTarget(target); }}>
|
||||||
|
Supprimer
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<button disabled={drSubmitting} onClick={closeDr}>Annuler</button>
|
||||||
|
<button className="primary" disabled={drSubmitting} onClick={() => runDrSubmit(submitDr)}>
|
||||||
|
{drSubmitting ? (drEditingId ? 'Enregistrement…' : 'Création…') : (drEditingId ? 'Enregistrer' : 'Créer')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<form onSubmit={e => runDrSubmit(submitDr, e)}>
|
||||||
|
{drErr && <div className="error">{drErr}</div>}
|
||||||
|
<div className="form-grid">
|
||||||
|
<div>
|
||||||
|
<label>Date *</label>
|
||||||
|
<input type="date" required value={drForm.date_operation} onChange={e => setDrForm({ ...drForm, date_operation: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Type *</label>
|
||||||
|
<select required value={drForm.type} onChange={e => setDrForm({ ...drForm, type: e.target.value })}>
|
||||||
|
<option value="depot">Dépôt</option>
|
||||||
|
<option value="retrait">Retrait</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Montant (€) *</label>
|
||||||
|
<input type="number" step="0.01" min="0" required value={drForm.montant} onChange={e => setDrForm({ ...drForm, montant: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div style={{ gridColumn: '1 / -1' }}>
|
||||||
|
<label>Libellé</label>
|
||||||
|
<input value={drForm.libelle} onChange={e => setDrForm({ ...drForm, libelle: e.target.value })} placeholder="Ex. Appel de fonds" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Référence</label>
|
||||||
|
<input value={drForm.reference} onChange={e => setDrForm({ ...drForm, reference: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
{multiDetenteur && (
|
||||||
|
<div style={{ gridColumn: '1 / -1' }}>
|
||||||
|
<label>Détenteur *</label>
|
||||||
|
<select required value={drForm.investisseur_id} onChange={e => setDrForm({ ...drForm, investisseur_id: e.target.value })}>
|
||||||
|
<option value="">—</option>
|
||||||
|
{investisseurs.map(i => <option key={i.id} value={i.id}>{i.nom}{i.is_principal ? ' (principal)' : ''}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ gridColumn: '1 / -1' }}>
|
||||||
|
<label>Notes</label>
|
||||||
|
<input value={drForm.notes} onChange={e => setDrForm({ ...drForm, notes: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<ConfirmModal
|
||||||
|
open={!!drDeleteTarget}
|
||||||
|
title="Supprimer ce mouvement ?"
|
||||||
|
message="Cette action est irréversible."
|
||||||
|
confirmLabel="Supprimer"
|
||||||
|
onConfirm={onDeleteDr}
|
||||||
|
onCancel={() => setDrDeleteTarget(null)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -209,7 +209,7 @@ const emptyRembForm = {
|
|||||||
* X-Workspace-Id (cf. workspaceScope.js, Étape 2a/2c du 17/09/26).
|
* X-Workspace-Id (cf. workspaceScope.js, Étape 2a/2c du 17/09/26).
|
||||||
*/
|
*/
|
||||||
export default function RemboursementsSimple() {
|
export default function RemboursementsSimple() {
|
||||||
const { activeId: investisseurId } = useInvestisseur();
|
const { activeId: investisseurId, activeView } = useInvestisseur();
|
||||||
const { active: activeWorkspace } = useWorkspace() || {};
|
const { active: activeWorkspace } = useWorkspace() || {};
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -258,10 +258,11 @@ export default function RemboursementsSimple() {
|
|||||||
const load = async () => {
|
const load = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
|
const scopeParams = activeView === 'all' ? { scope: 'all' } : {};
|
||||||
const [rb, pl, dl, pfu] = await Promise.all([
|
const [rb, pl, dl, pfu] = await Promise.all([
|
||||||
api.get('/remboursements'),
|
api.get('/remboursements', scopeParams),
|
||||||
api.get('/plateformes'),
|
api.get('/plateformes'),
|
||||||
api.get('/investissements-pe'),
|
api.get('/investissements-pe', scopeParams),
|
||||||
api.get('/pfu'),
|
api.get('/pfu'),
|
||||||
]);
|
]);
|
||||||
setRows(rb);
|
setRows(rb);
|
||||||
@@ -276,9 +277,10 @@ export default function RemboursementsSimple() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (investisseurId) load();
|
if (!investisseurId && activeView !== 'all') return;
|
||||||
|
load();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [investisseurId, activeWorkspace?.id]);
|
}, [investisseurId, activeView, activeWorkspace?.id]);
|
||||||
|
|
||||||
const investisseurForPlat = (platId) => {
|
const investisseurForPlat = (platId) => {
|
||||||
const plat = plats.find(p => String(p.id) === String(platId));
|
const plat = plats.find(p => String(p.id) === String(platId));
|
||||||
|
|||||||
Reference in new issue
Block a user