Implementation de la notion d'espace d'investissement
This commit is contained in:
1 parent
6b6cad7cd6
commit
4b7df66862
54 files changed
+6755
-261
No files matched your search
@@ -3069,6 +3069,22 @@ db.exec(`
|
||||
`);
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_user_workspaces_user ON user_workspaces(user_id)');
|
||||
|
||||
// ── Migration : couleur de thème par (utilisateur, workspace) (17/09/26) ──
|
||||
// Permet à l'utilisateur de personnaliser la couleur d'accent par espace de
|
||||
// travail (demande Olivier) : NULL = hérite de la couleur de l'application
|
||||
// (personnelle si définie via user_preferences.color_theme, sinon défaut
|
||||
// admin) — même énumération que color_theme (cf. COLOR_THEME_VALUES,
|
||||
// routes/preferences.js). Stocké sur user_workspaces (PK user_id+
|
||||
// workspace_id) plutôt que sur workspaces : c'est un réglage personnel par
|
||||
// compte, pas un réglage global de l'espace. Cf. AppearanceSection.jsx,
|
||||
// WorkspaceContext.jsx et UiContext.jsx (workspaceColorOverride).
|
||||
{
|
||||
const uwCols = db.prepare('PRAGMA table_info(user_workspaces)').all().map(c => c.name);
|
||||
if (!uwCols.includes('color_theme')) {
|
||||
db.exec('ALTER TABLE user_workspaces ADD COLUMN color_theme TEXT');
|
||||
}
|
||||
}
|
||||
|
||||
// Seed : workspace "crowdlending" = l'app actuelle. Idempotent (slug UNIQUE).
|
||||
db.prepare(`
|
||||
INSERT OR IGNORE INTO workspaces (slug, nom, libelle_menu, description, actif_global, ordre)
|
||||
@@ -3103,4 +3119,247 @@ export function grantDefaultWorkspace(userId) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Migration : Workspaces sur le référentiel plateformes (08/09/26) ─────
|
||||
// Chaque entrée du référentiel (plateformes_referentiel) est désormais
|
||||
// rattachée à un ou plusieurs workspaces (relation N-N, même patron que
|
||||
// referentiel_categories_inv/referentiel_secteurs_inv). Demande d'Olivier :
|
||||
// réaffectation obligatoire vers "crowdlending" pour ce qui existe déjà en
|
||||
// base, et au moins une affectation exigée à la création (validé côté API
|
||||
// par le schéma Zod de routes/referentiel.js, min(1) sur workspace_ids).
|
||||
// Placé ici (après la création de `workspaces` ci-dessus) pour garantir
|
||||
// que la table existe déjà. Cf. project_workspaces_transformation.md.
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS referentiel_workspaces (
|
||||
referentiel_id INTEGER NOT NULL REFERENCES plateformes_referentiel(id) ON DELETE CASCADE,
|
||||
workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (referentiel_id, workspace_id)
|
||||
)
|
||||
`);
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_ref_workspaces_ws ON referentiel_workspaces(workspace_id)');
|
||||
|
||||
// Backfill idempotent : toute entrée référentiel sans aucune affectation
|
||||
// (comptes créés avant cette migration, ou entrées jamais touchées depuis)
|
||||
// est rattachée au workspace "crowdlending" — seul workspace réel à ce jour.
|
||||
{
|
||||
const wsCrowdlending = db.prepare("SELECT id FROM workspaces WHERE slug = 'crowdlending'").get();
|
||||
if (wsCrowdlending) {
|
||||
db.prepare(`
|
||||
INSERT OR IGNORE INTO referentiel_workspaces (referentiel_id, workspace_id)
|
||||
SELECT pr.id, ?
|
||||
FROM plateformes_referentiel pr
|
||||
WHERE NOT EXISTS (SELECT 1 FROM referentiel_workspaces rw WHERE rw.referentiel_id = pr.id)
|
||||
`).run(wsCrowdlending.id);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Migration : Workspaces sur les plateformes utilisateur (08/09/26) ────
|
||||
// Extension du même principe que le référentiel ci-dessus : chaque
|
||||
// plateforme UTILISATEUR (table `plateformes`, distincte du référentiel
|
||||
// admin) peut désormais être rattachée à un ou plusieurs workspaces (N-N,
|
||||
// même patron `xxx_workspaces`). Décision d'Olivier (AskUserQuestion,
|
||||
// 08/09/26) : un compte sur une plateforme peut servir à la fois au
|
||||
// crowdlending et à un futur workspace Private Equity — pas de colonne
|
||||
// unique comme initialement envisagé pour l'Étape 2 du retrofit général.
|
||||
// Backfill : toute plateforme déjà en base est rattachée à "crowdlending".
|
||||
// Cf. project_workspaces_transformation.md.
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS plateforme_workspaces (
|
||||
plateforme_id INTEGER NOT NULL REFERENCES plateformes(id) ON DELETE CASCADE,
|
||||
workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (plateforme_id, workspace_id)
|
||||
)
|
||||
`);
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_plat_workspaces_ws ON plateforme_workspaces(workspace_id)');
|
||||
|
||||
// Backfill idempotent : toute plateforme utilisateur sans aucune affectation
|
||||
// est rattachée au workspace "crowdlending" — seul workspace réel à ce jour.
|
||||
{
|
||||
const wsCrowdlending = db.prepare("SELECT id FROM workspaces WHERE slug = 'crowdlending'").get();
|
||||
if (wsCrowdlending) {
|
||||
db.prepare(`
|
||||
INSERT OR IGNORE INTO plateforme_workspaces (plateforme_id, workspace_id)
|
||||
SELECT p.id, ?
|
||||
FROM plateformes p
|
||||
WHERE NOT EXISTS (SELECT 1 FROM plateforme_workspaces pw WHERE pw.plateforme_id = p.id)
|
||||
`).run(wsCrowdlending.id);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Migration : workspace_id sur depots_retraits (17/09/26) ─────────────
|
||||
// Contrairement à plateformes/plateformes_referentiel (relation N-N — un compte
|
||||
// pouvant servir plusieurs workspaces à la fois), Olivier a tranché le 17/09/26
|
||||
// pour une colonne UNIQUE sur depots_retraits : chaque mouvement de trésorerie
|
||||
// est "flêché" vers un seul workspace précis. Démarre, table par table, l'Étape 1
|
||||
// du retrofit workspace_id documenté dans project_workspaces_transformation.md —
|
||||
// dépôts/retraits en premier, à la demande explicite d'Olivier (remboursements
|
||||
// ensuite). Colonne nullable, backfill de l'existant vers "crowdlending".
|
||||
{
|
||||
const drCols2 = db.prepare('PRAGMA table_info(depots_retraits)').all().map(c => c.name);
|
||||
if (!drCols2.includes('workspace_id')) {
|
||||
db.exec('ALTER TABLE depots_retraits ADD COLUMN workspace_id INTEGER REFERENCES workspaces(id)');
|
||||
}
|
||||
}
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_depret_workspace ON depots_retraits(workspace_id)');
|
||||
|
||||
// Backfill idempotent : tout dépôt/retrait sans workspace (créé avant cette
|
||||
// migration) est rattaché à "crowdlending" — seul workspace réel à ce jour.
|
||||
{
|
||||
const wsCrowdlending2 = db.prepare("SELECT id FROM workspaces WHERE slug = 'crowdlending'").get();
|
||||
if (wsCrowdlending2) {
|
||||
db.prepare('UPDATE depots_retraits SET workspace_id = ? WHERE workspace_id IS NULL').run(wsCrowdlending2.id);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Migration : workspace_id sur remboursements (17/09/26) ──────────────
|
||||
// Suite du retrofit workspace_id (project_workspaces_transformation.md),
|
||||
// table par table à la demande d'Olivier : après depots_retraits, les
|
||||
// remboursements. Même principe : colonne unique nullable, backfill de
|
||||
// l'existant vers "crowdlending".
|
||||
{
|
||||
const rembCols = db.prepare('PRAGMA table_info(remboursements)').all().map(c => c.name);
|
||||
if (!rembCols.includes('workspace_id')) {
|
||||
db.exec('ALTER TABLE remboursements ADD COLUMN workspace_id INTEGER REFERENCES workspaces(id)');
|
||||
}
|
||||
}
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_remb_workspace ON remboursements(workspace_id)');
|
||||
|
||||
// Backfill idempotent : tout remboursement sans workspace (créé avant cette
|
||||
// migration) est rattaché à "crowdlending" — seul workspace réel à ce jour.
|
||||
{
|
||||
const wsCrowdlending3 = db.prepare("SELECT id FROM workspaces WHERE slug = 'crowdlending'").get();
|
||||
if (wsCrowdlending3) {
|
||||
db.prepare('UPDATE remboursements SET workspace_id = ? WHERE workspace_id IS NULL').run(wsCrowdlending3.id);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Investissements Private Equity (17/09/26) ────────────────────────────
|
||||
// Nouveau modèle de données, volontairement séparé de `investissements`
|
||||
// (cf. project_workspaces_transformation.md) : un deal PE (ex. Fundora) n'a
|
||||
// ni échéancier connu à l'avance, ni taux d'intérêt fixe — c'est un montant
|
||||
// unique investi (même principe que le crowdlending, décision Olivier
|
||||
// 17/09/26 : le porte-monnaie diminue d'un coup, pas de suivi souscrit/
|
||||
// décaissé), valorisé périodiquement par le fonds, avec un objectif de
|
||||
// performance hétérogène selon les deals (multiple "x4,5" ou fourchette de
|
||||
// taux annuel "9-12 %/an") — on le stocke donc en texte libre plutôt que
|
||||
// d'essayer de le structurer. Table neuve : workspace_id est NOT NULL dès
|
||||
// la création, pas besoin du nullable+backfill utilisé pour les tables
|
||||
// préexistantes (depots_retraits, remboursements).
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS investissements_pe (
|
||||
id INTEGER PRIMARY KEY,
|
||||
investisseur_id INTEGER NOT NULL REFERENCES investisseurs(id) ON DELETE CASCADE,
|
||||
plateforme_id INTEGER NOT NULL REFERENCES plateformes(id) ON DELETE CASCADE,
|
||||
workspace_id INTEGER NOT NULL REFERENCES workspaces(id),
|
||||
nom_deal TEXT NOT NULL,
|
||||
strategie TEXT, -- texte libre : 'Capital développement', 'Capital risque', 'Dette'...
|
||||
date_souscription TEXT NOT NULL,
|
||||
duree_mois INTEGER,
|
||||
montant_investi REAL NOT NULL CHECK(montant_investi > 0),
|
||||
objectif TEXT, -- texte libre : "x4,5" ou "9 % - 12 %/an"
|
||||
statut TEXT NOT NULL DEFAULT 'valide'
|
||||
CHECK(statut IN ('en_attente','valide','cloture','perte_definitive')),
|
||||
reference TEXT,
|
||||
source TEXT NOT NULL DEFAULT 'manuel',
|
||||
notes TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
`);
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_invpe_inv ON investissements_pe(investisseur_id)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_invpe_plat ON investissements_pe(plateforme_id)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_invpe_ws ON investissements_pe(workspace_id)');
|
||||
|
||||
// ── Migration : frais PE — investissement_pe_id (17/09/26) ─────────────────
|
||||
// frais_operations était exclusivement rattachée à investissements (crowdlending).
|
||||
// Pour permettre la traçabilité des frais des deals PE (investissements_pe), on ajoute
|
||||
// une colonne investissement_pe_id nullable, on desserre investissement_id (désormais
|
||||
// nullable lui aussi), et on impose par CHECK qu'exactement une des deux références
|
||||
// soit renseignée. Reconstruction de table (SQLite ne permet pas d'altérer une
|
||||
// contrainte NOT NULL ni un CHECK en place) — même stratégie que les migrations
|
||||
// précédentes de frais_operations, cf. plus haut dans ce fichier.
|
||||
{
|
||||
const fraisOpColsPe = db.prepare('PRAGMA table_info(frais_operations)').all().map(c => c.name);
|
||||
if (!fraisOpColsPe.includes('investissement_pe_id')) {
|
||||
const tempName = '__repair_frais_operations_pe';
|
||||
|
||||
const fixedDdl = `
|
||||
CREATE TABLE "${tempName}" (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
investissement_id INTEGER REFERENCES investissements(id) ON DELETE CASCADE,
|
||||
investissement_pe_id INTEGER REFERENCES investissements_pe(id) ON DELETE CASCADE,
|
||||
plateforme_id INTEGER NOT NULL REFERENCES plateformes(id) ON DELETE CASCADE,
|
||||
remboursement_id INTEGER REFERENCES remboursements(id) ON DELETE CASCADE,
|
||||
montant REAL NOT NULL,
|
||||
mode_reglement TEXT NOT NULL DEFAULT 'source'
|
||||
CHECK(mode_reglement IN ('source','portefeuille','compte_courant','remboursement')),
|
||||
compte_id INTEGER REFERENCES comptes(id) ON DELETE SET NULL,
|
||||
date_operation TEXT NOT NULL,
|
||||
origine TEXT NOT NULL DEFAULT 'manuel'
|
||||
CHECK(origine IN ('souscription','remboursement','manuel')),
|
||||
categorie TEXT NOT NULL DEFAULT 'autre',
|
||||
notes TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
CHECK ((investissement_id IS NOT NULL) + (investissement_pe_id IS NOT NULL) = 1)
|
||||
)
|
||||
`;
|
||||
|
||||
const colDefs = db.prepare('PRAGMA table_info(frais_operations)').all();
|
||||
const colNames = colDefs.map(c => `"${c.name}"`).join(', ');
|
||||
const idxs = db.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='frais_operations' AND sql IS NOT NULL"
|
||||
).all();
|
||||
|
||||
db.exec('PRAGMA foreign_keys = OFF');
|
||||
db.exec(`DROP TABLE IF EXISTS "${tempName}"`);
|
||||
db.exec(fixedDdl);
|
||||
db.exec(`INSERT INTO "${tempName}" (${colNames}) SELECT ${colNames} FROM frais_operations`);
|
||||
for (const idx of idxs) db.exec(`DROP INDEX IF EXISTS "${idx.name}"`);
|
||||
db.exec('DROP TABLE frais_operations');
|
||||
// legacy_alter_table = ON : empêche la réécriture automatique des FK enfants
|
||||
db.exec('PRAGMA legacy_alter_table = ON');
|
||||
db.exec(`ALTER TABLE "${tempName}" RENAME TO frais_operations`);
|
||||
db.exec('PRAGMA legacy_alter_table = OFF');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_fraisop_inv ON frais_operations(investissement_id)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_fraisop_inv_pe ON frais_operations(investissement_pe_id)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_fraisop_plat ON frais_operations(plateforme_id)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_fraisop_remb ON frais_operations(remboursement_id)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_fraisop_date ON frais_operations(date_operation)');
|
||||
}
|
||||
}
|
||||
|
||||
// Historique des valorisations (relevés périodiques du fonds) — une ligne par
|
||||
// date de valorisation, plutôt qu'un champ unique écrasé à chaque mise à jour,
|
||||
// pour permettre un graphique d'évolution et un TVPI recalculable à toute date
|
||||
// passée (décision Olivier 17/09/26).
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS investissements_pe_valorisations (
|
||||
id INTEGER PRIMARY KEY,
|
||||
investissement_pe_id INTEGER NOT NULL REFERENCES investissements_pe(id) ON DELETE CASCADE,
|
||||
date_valorisation TEXT NOT NULL,
|
||||
valorisation_nette REAL NOT NULL,
|
||||
notes TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
`);
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_invpeval_inv ON investissements_pe_valorisations(investissement_pe_id)');
|
||||
|
||||
// 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
|
||||
// remboursement bonus_plateforme/interets_plateforme, comme demandé par
|
||||
// Olivier ("comme un remboursement, page déjà construite") — ce lien
|
||||
// supplémentaire permet de rattacher cette distribution à un deal précis
|
||||
// (indispensable quand plusieurs deals partagent une même plateforme, ex.
|
||||
// Fundora) afin de calculer un TVPI par deal plutôt que par plateforme.
|
||||
// Nullable et sans effet sur les remboursements crowdlending existants.
|
||||
{
|
||||
const rembCols2 = db.prepare('PRAGMA table_info(remboursements)').all().map(c => c.name);
|
||||
if (!rembCols2.includes('investissement_pe_id')) {
|
||||
db.exec('ALTER TABLE remboursements ADD COLUMN investissement_pe_id INTEGER REFERENCES investissements_pe(id)');
|
||||
}
|
||||
}
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_remb_invpe ON remboursements(investissement_pe_id)');
|
||||
|
||||
export default db;
|
||||
@@ -0,0 +1,37 @@
|
||||
import db from '../db/index.js';
|
||||
|
||||
/**
|
||||
* Résout l'ensemble des workspace_id qu'un utilisateur a le droit d'utiliser :
|
||||
* accordés par l'admin (granted_by_admin) ET activés par l'utilisateur (active_by_user).
|
||||
* Miroir serveur exact de useWorkspace().activeWorkspaces côté frontend
|
||||
* (frontend/src/context/WorkspaceContext.jsx). Centralisé ici (17/09/26) car utilisé
|
||||
* par plusieurs routes (plateformes, depots_retraits, imports, remboursements, ...).
|
||||
*/
|
||||
export function getUserActiveWorkspaceIds(userId) {
|
||||
return db.prepare(`
|
||||
SELECT w.id
|
||||
FROM user_workspaces uw
|
||||
JOIN workspaces w ON w.id = uw.workspace_id
|
||||
WHERE uw.user_id = ? AND uw.granted_by_admin = 1 AND uw.active_by_user = 1 AND w.actif_global = 1
|
||||
`).all(userId).map(r => r.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Résout le workspace_id "actif" à stamper sur une ligne créée côté serveur (dépôt/retrait
|
||||
* pour l'instant — Étape 2a du retrofit workspace_id, cf. project_workspaces_transformation.md).
|
||||
* Lu depuis le header X-Workspace-Id, envoyé automatiquement par le frontend
|
||||
* (frontend/src/api.js, miroir de X-Investisseur-Id) et validé contre les workspaces actifs
|
||||
* de l'utilisateur. Si le header est absent, invalide, ou non autorisé (ex. appel API v1/MCP
|
||||
* qui ne l'envoie pas encore), retombe silencieusement sur le workspace "crowdlending" —
|
||||
* jamais d'échec bloquant, pour rester transparent tant qu'un seul workspace existe réellement.
|
||||
*/
|
||||
export function resolveActiveWorkspaceId(req) {
|
||||
const raw = req.header('X-Workspace-Id');
|
||||
const id = Number(raw);
|
||||
if (id) {
|
||||
const allowed = new Set(getUserActiveWorkspaceIds(req.user.id));
|
||||
if (allowed.has(id)) return id;
|
||||
}
|
||||
const cl = db.prepare("SELECT id FROM workspaces WHERE slug = 'crowdlending'").get();
|
||||
return cl ? cl.id : null;
|
||||
}
|
||||
@@ -184,7 +184,13 @@ router.get('/me', requireAuth, (req, res) => {
|
||||
JOIN investissements i ON i.id = f.investissement_id
|
||||
JOIN investisseurs inv ON inv.id = i.investisseur_id
|
||||
WHERE inv.user_id = users.id
|
||||
) AS has_frais
|
||||
) AS has_frais,
|
||||
EXISTS(
|
||||
SELECT 1 FROM frais_operations f
|
||||
JOIN investissements_pe ipe ON ipe.id = f.investissement_pe_id
|
||||
JOIN investisseurs inv ON inv.id = ipe.investisseur_id
|
||||
WHERE inv.user_id = users.id
|
||||
) AS has_frais_pe
|
||||
FROM users WHERE id = ?
|
||||
`)
|
||||
.get(req.user.id);
|
||||
|
||||
@@ -125,6 +125,19 @@ router.get('/', (req, res) => {
|
||||
GROUP BY i.plateforme_id
|
||||
`).all(...(asOf ? [asOf] : []), ...invParams, ...(asOf ? [asOf] : []));
|
||||
|
||||
// Capital investi Private Equity (investissements_pe.montant_investi) — une
|
||||
// plateforme peut être partagée entre le workspace crowdlending et le
|
||||
// workspace Private Equity (ex. Fundora) : le porte-monnaie réel chez le
|
||||
// courtier est UNIQUE, donc le capital englouti dans un deal PE doit aussi
|
||||
// réduire le porte-monnaie affiché ici, même si cette page est celle du
|
||||
// workspace crowdlending (17/09/26).
|
||||
const capitalInvestiPePerPlat = db.prepare(`
|
||||
SELECT plateforme_id, COALESCE(SUM(montant_investi), 0) AS capital_investi
|
||||
FROM investissements_pe
|
||||
WHERE ${invWhere}${asOf ? ' AND date_souscription <= ?' : ''}
|
||||
GROUP BY plateforme_id
|
||||
`).all(...invParams, ...(asOf ? [asOf] : []));
|
||||
|
||||
// Frais d'opération (Étape 3, 31/08/26) : un frais réglé 'source' ou 'portefeuille'
|
||||
// réduit le solde du porte-monnaie. 'source' s'ajoute à la réduction déjà appliquée via
|
||||
// capitalInvestiPerPlat (qui soustrait montant_investi déjà net) — cas confirmé par Olivier
|
||||
@@ -158,6 +171,7 @@ router.get('/', (req, res) => {
|
||||
for (const r of bonusWalletPerPlat) walletMap[r.plateforme_id] = (walletMap[r.plateforme_id] || 0) + r.bonus_wallet;
|
||||
for (const r of interetsPlateformeWalletPerPlat) walletMap[r.plateforme_id] = (walletMap[r.plateforme_id] || 0) + r.interets_plateforme_wallet;
|
||||
for (const r of capitalInvestiPerPlat) walletMap[r.plateforme_id] = (walletMap[r.plateforme_id] || 0) - r.capital_investi;
|
||||
for (const r of capitalInvestiPePerPlat) walletMap[r.plateforme_id] = (walletMap[r.plateforme_id] || 0) - r.capital_investi;
|
||||
for (const r of fraisPerPlat) walletMap[r.plateforme_id] = (walletMap[r.plateforme_id] || 0) - r.total_frais;
|
||||
for (const r of correctionsPerPlat) walletMap[r.plateforme_id] = (walletMap[r.plateforme_id] || 0) + r.total_correction;
|
||||
|
||||
@@ -358,6 +372,14 @@ router.get('/solde-historique', (req, res) => {
|
||||
AND i.date_souscription <= ?
|
||||
`).get(date, platId, ...invParams, date);
|
||||
|
||||
// Capital investi Private Equity sur cette même plateforme (cf. GET / —
|
||||
// capitalInvestiPePerPlat) : même porte-monnaie physique, même correction.
|
||||
const capitalPe = db.prepare(`
|
||||
SELECT COALESCE(SUM(montant_investi), 0) AS capital_investi
|
||||
FROM investissements_pe
|
||||
WHERE plateforme_id = ? AND ${invWhere} AND date_souscription <= ?
|
||||
`).get(platId, ...invParams, date);
|
||||
|
||||
/* ── 5. Frais d'opération 'source'/'portefeuille' (jusqu'à :date) ─── */
|
||||
// Cf. GET / ci-dessus (fraisPerPlat) — même logique, même exclusion de compte_courant.
|
||||
const frais = db.prepare(`
|
||||
@@ -382,6 +404,7 @@ router.get('/solde-historique', (req, res) => {
|
||||
+ remb.remb_wallet
|
||||
+ bonus.bonus_wallet
|
||||
- capital.capital_investi
|
||||
- capitalPe.capital_investi
|
||||
- frais.total_frais
|
||||
+ corrections.total_correction
|
||||
) * 100) / 100;
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
import { Router } from 'express';
|
||||
import db from '../db/index.js';
|
||||
import { resolveActiveWorkspaceId } from '../middleware/workspaceScope.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* GET /api/dashboard-pe
|
||||
*
|
||||
* Équivalent Private Equity de GET /api/dashboard (dashboard.js).
|
||||
*
|
||||
* Deux notions bien séparées ici (17/09/26, suite à un bug signalé par
|
||||
* Olivier) :
|
||||
* - cashByPlatform (Dépôts/Retraits/Diff affichés, KPIs du haut de page) :
|
||||
* borné au workspace actif — c'est une vue "intention" (combien j'ai
|
||||
* déposé/retiré POUR le Private Equity), légitimement différente de la
|
||||
* même vue côté crowdlending.
|
||||
* - solde_portefeuille (le porte-monnaie par plateforme) : agrégé sur TOUS
|
||||
* les workspaces, sans filtre workspace_id. Une plateforme peut être
|
||||
* partagée entre crowdlending et PE (ex. Fundora) ; son porte-monnaie
|
||||
* physique chez le courtier est UNIQUE, donc le calcul doit tenir compte
|
||||
* des dépôts/retraits/investissements des DEUX côtés, quel que soit le
|
||||
* workspace consulté. Formule strictement identique à celle de
|
||||
* GET /api/dashboard (dépôts - retraits manuels + remboursements portefeuille
|
||||
* + bonus/intérêts plateforme - capital investi crowdlending
|
||||
* (investissements + réinvestissements) - capital investi PE
|
||||
* (investissements_pe.montant_investi) - frais - + corrections) : les deux
|
||||
* endpoints doivent toujours reporter le MÊME porte-monnaie pour une
|
||||
* plateforme partagée.
|
||||
*
|
||||
* ?scope=all → agrège tous les investisseurs de l'utilisateur (sinon filtré par
|
||||
* l'en-tête X-Investisseur-Id, comme /api/dashboard).
|
||||
* ?asOf=YYYY-MM-DD → cumule uniquement jusqu'à cette date (snapshot fin d'année,
|
||||
* même principe que /api/dashboard, pour les comparaisons N/N-1 du frontend).
|
||||
*/
|
||||
router.get('/', (req, res) => {
|
||||
const scopeAll = req.query.scope === 'all';
|
||||
const userId = req.user.id;
|
||||
const workspaceId = resolveActiveWorkspaceId(req);
|
||||
|
||||
let invWhere, invParams;
|
||||
if (scopeAll) {
|
||||
invWhere = 'investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)';
|
||||
invParams = [userId];
|
||||
} else {
|
||||
const raw = req.header('X-Investisseur-Id');
|
||||
const invId = Number(raw);
|
||||
if (!invId) 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(invId, userId);
|
||||
if (!row) return res.status(403).json({ error: 'Investisseur not found or not owned by user' });
|
||||
invWhere = 'investisseur_id = ?';
|
||||
invParams = [invId];
|
||||
}
|
||||
|
||||
const asOf = /^\d{4}-\d{2}-\d{2}$/.test(req.query.asOf || '') ? req.query.asOf : null;
|
||||
|
||||
/* ── Cash global (dépôts/retraits, workspace actif) ─────────────── */
|
||||
const cash = db.prepare(`
|
||||
SELECT
|
||||
COALESCE(SUM(CASE WHEN type='depot' THEN montant END),0) AS total_depots,
|
||||
COALESCE(SUM(CASE WHEN type='retrait' THEN montant END),0) AS total_retraits
|
||||
FROM depots_retraits
|
||||
WHERE ${invWhere} AND workspace_id = ?${asOf ? ' AND date_operation <= ?' : ''}
|
||||
`).get(...invParams, workspaceId, ...(asOf ? [asOf] : []));
|
||||
|
||||
/* ── Dépôts/retraits par plateforme (plateformes du workspace actif uniquement) ── */
|
||||
const cashByPlatform = db.prepare(`
|
||||
SELECT p.id AS plateforme_id, p.nom,
|
||||
plat_inv.nom AS detenteur_nom,
|
||||
COALESCE(SUM(CASE WHEN dr.type='depot' THEN dr.montant END),0) AS depots,
|
||||
COALESCE(SUM(CASE WHEN dr.type='retrait' THEN dr.montant END),0) AS retraits,
|
||||
COALESCE(SUM(CASE WHEN dr.type='depot' THEN dr.montant
|
||||
WHEN dr.type='retrait' THEN -dr.montant END),0) AS solde_net
|
||||
FROM plateformes p
|
||||
JOIN plateforme_workspaces pw ON pw.plateforme_id = p.id AND pw.workspace_id = ?
|
||||
LEFT JOIN investisseurs plat_inv ON plat_inv.id = p.investisseur_id
|
||||
LEFT JOIN depots_retraits dr ON dr.plateforme_id = p.id AND dr.workspace_id = ?
|
||||
AND ${invWhere.replace('investisseur_id', 'dr.investisseur_id')}
|
||||
${asOf ? 'AND dr.date_operation <= ?' : ''}
|
||||
WHERE p.user_id = ?
|
||||
GROUP BY p.id, p.nom, plat_inv.nom
|
||||
ORDER BY p.nom
|
||||
`).all(workspaceId, workspaceId, ...invParams, ...(asOf ? [asOf] : []), userId);
|
||||
|
||||
/* ── Solde porte-monnaie par plateforme ──────────────────────────────
|
||||
* Une plateforme peut être partagée entre plusieurs workspaces (ex.
|
||||
* Fundora, à la fois crowdlending et PE) : son porte-monnaie physique
|
||||
* chez le courtier est UNIQUE, donc ce calcul agrège TOUS les workspaces
|
||||
* (aucun filtre workspace_id ici, volontairement) plutôt que de se borner
|
||||
* au seul workspace actif — sinon le porte-monnaie PE ignorerait les
|
||||
* dépôts/investissements crowdlending sur cette même plateforme, comme
|
||||
* son miroir GET /api/dashboard ignorait jusqu'ici le capital PE
|
||||
* (bug signalé par Olivier le 17/09/26). Formule strictement identique à
|
||||
* celle de GET /api/dashboard (dashboard.js) — seul cashByPlatform
|
||||
* ci-dessus (Dépôts/Retraits affichés) reste borné au workspace actif,
|
||||
* lui, car il sert de vue "intention" par workspace, pas le solde réel. */
|
||||
const drManuelPerPlat = db.prepare(`
|
||||
SELECT plateforme_id,
|
||||
COALESCE(SUM(CASE WHEN type='depot' THEN montant ELSE 0 END), 0) AS depots,
|
||||
COALESCE(SUM(CASE WHEN type='retrait' AND COALESCE(source,'') != 'auto_remboursement'
|
||||
THEN montant ELSE 0 END), 0) AS retraits_manuels
|
||||
FROM depots_retraits
|
||||
WHERE ${invWhere}${asOf ? ' AND date_operation <= ?' : ''}
|
||||
GROUP BY plateforme_id
|
||||
`).all(...invParams, ...(asOf ? [asOf] : []));
|
||||
|
||||
// Remboursements crédités directement au portefeuille (type='normal',
|
||||
// méthode='portefeuille') — concept crowdlending uniquement (PE ne
|
||||
// connaît que bonus_plateforme/interets_plateforme), mais peut concerner
|
||||
// une plateforme partagée : à agréger ici aussi pour la même raison.
|
||||
const rembWalletPerPlat = db.prepare(`
|
||||
SELECT i.plateforme_id,
|
||||
COALESCE(SUM(
|
||||
CASE p.fiscalite
|
||||
WHEN 'flat_tax' THEN r.net_recu
|
||||
ELSE r.capital + r.cashback + r.interets_bruts
|
||||
END
|
||||
), 0) AS remb_wallet
|
||||
FROM remboursements r
|
||||
JOIN investissements i ON i.id = r.investissement_id
|
||||
JOIN plateformes p ON p.id = i.plateforme_id
|
||||
WHERE ${invWhere.replace('investisseur_id', 'i.investisseur_id')}
|
||||
AND r.type = 'normal'
|
||||
AND r.methode_remboursement = 'portefeuille'
|
||||
${asOf ? 'AND r.date_remb <= ?' : ''}
|
||||
GROUP BY i.plateforme_id
|
||||
`).all(...invParams, ...(asOf ? [asOf] : []));
|
||||
|
||||
const capitalInvestiPePerPlat = db.prepare(`
|
||||
SELECT plateforme_id, COALESCE(SUM(montant_investi), 0) AS capital_investi
|
||||
FROM investissements_pe
|
||||
WHERE ${invWhere}${asOf ? ' AND date_souscription <= ?' : ''}
|
||||
GROUP BY plateforme_id
|
||||
`).all(...invParams, ...(asOf ? [asOf] : []));
|
||||
|
||||
// Capital investi crowdlending (investissements + réinvestissements) —
|
||||
// même raison de partage de plateforme que ci-dessus.
|
||||
const capitalInvestiPerPlat = db.prepare(`
|
||||
SELECT i.plateforme_id,
|
||||
COALESCE(SUM(
|
||||
i.montant_investi
|
||||
+ COALESCE((SELECT SUM(rv.montant) FROM reinvestissements rv WHERE rv.investissement_id = i.id${asOf ? ' AND rv.date_reinvestissement <= ?' : ''}), 0)
|
||||
), 0) AS capital_investi
|
||||
FROM investissements i
|
||||
WHERE ${invWhere.replace('investisseur_id', 'i.investisseur_id')}
|
||||
${asOf ? 'AND i.date_souscription <= ?' : ''}
|
||||
GROUP BY i.plateforme_id
|
||||
`).all(...(asOf ? [asOf] : []), ...invParams, ...(asOf ? [asOf] : []));
|
||||
|
||||
const bonusWalletPerPlat = db.prepare(`
|
||||
SELECT bonus_plateforme_id AS plateforme_id,
|
||||
COALESCE(SUM(cashback), 0) AS bonus_wallet
|
||||
FROM remboursements
|
||||
WHERE ${invWhere.replace('investisseur_id', 'bonus_investisseur_id')}
|
||||
AND type IN ('bonus_parrainage', 'bonus_plateforme')
|
||||
${asOf ? 'AND date_remb <= ?' : ''}
|
||||
GROUP BY bonus_plateforme_id
|
||||
`).all(...invParams, ...(asOf ? [asOf] : []));
|
||||
|
||||
// Intérêts plateforme : comme les bonus, ils créditent directement le
|
||||
// porte-monnaie de la plateforme (net_recu tient déjà compte des prélèvements).
|
||||
const interetsPlateformeWalletPerPlat = db.prepare(`
|
||||
SELECT bonus_plateforme_id AS plateforme_id,
|
||||
COALESCE(SUM(net_recu), 0) AS interets_plateforme_wallet
|
||||
FROM remboursements
|
||||
WHERE ${invWhere.replace('investisseur_id', 'bonus_investisseur_id')}
|
||||
AND type = 'interets_plateforme'
|
||||
${asOf ? 'AND date_remb <= ?' : ''}
|
||||
GROUP BY bonus_plateforme_id
|
||||
`).all(...invParams, ...(asOf ? [asOf] : []));
|
||||
|
||||
// Frais d'opération et corrections de solde — concepts crowdlending
|
||||
// uniquement (cf. GET /api/dashboard), mais réduisent/ajustent le même
|
||||
// porte-monnaie physique sur une plateforme partagée.
|
||||
const fraisPerPlat = db.prepare(`
|
||||
SELECT f.plateforme_id,
|
||||
COALESCE(SUM(f.montant), 0) AS total_frais
|
||||
FROM frais_operations f
|
||||
JOIN investissements i ON i.id = f.investissement_id
|
||||
WHERE ${invWhere.replace('investisseur_id', 'i.investisseur_id')}
|
||||
AND f.mode_reglement IN ('source', 'portefeuille')
|
||||
${asOf ? 'AND f.date_operation <= ?' : ''}
|
||||
GROUP BY f.plateforme_id
|
||||
`).all(...invParams, ...(asOf ? [asOf] : []));
|
||||
|
||||
const correctionsPerPlat = db.prepare(`
|
||||
SELECT plateforme_id,
|
||||
COALESCE(SUM(montant), 0) AS total_correction
|
||||
FROM corrections_solde
|
||||
WHERE ${invWhere}${asOf ? ' AND date <= ?' : ''}
|
||||
GROUP BY plateforme_id
|
||||
`).all(...invParams, ...(asOf ? [asOf] : []));
|
||||
|
||||
const walletMap = {};
|
||||
for (const r of drManuelPerPlat) walletMap[r.plateforme_id] = (walletMap[r.plateforme_id] || 0) + r.depots - r.retraits_manuels;
|
||||
for (const r of rembWalletPerPlat) walletMap[r.plateforme_id] = (walletMap[r.plateforme_id] || 0) + r.remb_wallet;
|
||||
for (const r of bonusWalletPerPlat) walletMap[r.plateforme_id] = (walletMap[r.plateforme_id] || 0) + r.bonus_wallet;
|
||||
for (const r of interetsPlateformeWalletPerPlat) walletMap[r.plateforme_id] = (walletMap[r.plateforme_id] || 0) + r.interets_plateforme_wallet;
|
||||
for (const r of capitalInvestiPerPlat) walletMap[r.plateforme_id] = (walletMap[r.plateforme_id] || 0) - r.capital_investi;
|
||||
for (const r of capitalInvestiPePerPlat) walletMap[r.plateforme_id] = (walletMap[r.plateforme_id] || 0) - r.capital_investi;
|
||||
for (const r of fraisPerPlat) walletMap[r.plateforme_id] = (walletMap[r.plateforme_id] || 0) - r.total_frais;
|
||||
for (const r of correctionsPerPlat) walletMap[r.plateforme_id] = (walletMap[r.plateforme_id] || 0) + r.total_correction;
|
||||
|
||||
const cashByPlatformEnriched = cashByPlatform.map(p => ({
|
||||
...p,
|
||||
solde_portefeuille: Math.round((walletMap[p.plateforme_id] || 0) * 100) / 100,
|
||||
}));
|
||||
|
||||
const solde_portefeuille_total = Math.round(
|
||||
cashByPlatformEnriched.reduce((s, p) => s + p.solde_portefeuille, 0) * 100
|
||||
) / 100;
|
||||
|
||||
res.json({ cash, cashByPlatform: cashByPlatformEnriched, solde_portefeuille_total });
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -3,6 +3,7 @@ import { z } from 'zod';
|
||||
import db from '../db/index.js';
|
||||
import { HttpError } from '../middleware/errorHandler.js';
|
||||
import { requireInvestisseur } from '../middleware/investisseurScope.js';
|
||||
import { resolveActiveWorkspaceId } from '../middleware/workspaceScope.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -52,6 +53,12 @@ router.get('/', (req, res) => {
|
||||
|
||||
const { from, to, type, plateforme_id } = req.query;
|
||||
const conds = [invCond];
|
||||
// Étape 2c du retrofit workspace_id (17/09/26) : la lecture respecte désormais le
|
||||
// workspace actif (même résolution qu'à l'écriture, cf. workspaceScope.js). Sans
|
||||
// effet aujourd'hui (100% des lignes sont "crowdlending", seul workspace atteignable
|
||||
// sur cette page) — devient réellement filtrant dès qu'un 2e workspace existera.
|
||||
conds.push('dr.workspace_id = ?');
|
||||
args.push(resolveActiveWorkspaceId(req));
|
||||
if (from) { conds.push('dr.date_operation >= ?'); args.push(from); }
|
||||
if (to) { conds.push('dr.date_operation <= ?'); args.push(to); }
|
||||
if (type) { conds.push('dr.type = ?'); args.push(type); }
|
||||
@@ -73,13 +80,16 @@ router.post('/', requireInvestisseur, (req, res, next) => {
|
||||
try {
|
||||
const body = Schema.parse(req.body);
|
||||
const investisseurId = resolveInvestisseurId(req, body.investisseur_id);
|
||||
// Étape 2a du retrofit workspace_id (17/09/26) : chaque dépôt/retrait créé
|
||||
// manuellement est flêché vers le workspace actif au moment de la création.
|
||||
const workspaceId = resolveActiveWorkspaceId(req);
|
||||
const r = db.prepare(`
|
||||
INSERT INTO depots_retraits
|
||||
(investisseur_id, plateforme_id, date_operation, type, montant, libelle, reference, source, notes)
|
||||
VALUES (?,?,?,?,?,?,?, 'manuel', ?)
|
||||
(investisseur_id, plateforme_id, date_operation, type, montant, libelle, reference, source, notes, workspace_id)
|
||||
VALUES (?,?,?,?,?,?,?, 'manuel', ?, ?)
|
||||
`).run(
|
||||
investisseurId, body.plateforme_id, body.date_operation, body.type,
|
||||
body.montant, body.libelle || null, body.reference || null, body.notes || null,
|
||||
body.montant, body.libelle || null, body.reference || null, body.notes || null, workspaceId,
|
||||
);
|
||||
res.status(201).json({ id: r.lastInsertRowid, ...body });
|
||||
} catch (e) { next(e); }
|
||||
|
||||
@@ -195,6 +195,18 @@ function handleUpload(req, res, next) {
|
||||
if (!owned) throw new HttpError(404, 'Investissement introuvable');
|
||||
}
|
||||
|
||||
// Équivalent pour les deals Private Equity (chantier "fiche détail PE", 17/09/26) — même
|
||||
// vérification de propriété, jointure via investissements_pe au lieu de investissements.
|
||||
if (entityType === 'investissement_pe') {
|
||||
if (!entityId) throw new HttpError(400, 'entity_id est requis pour la catégorie investissement_pe');
|
||||
const owned = db.prepare(`
|
||||
SELECT ipe.id FROM investissements_pe ipe
|
||||
JOIN investisseurs inv ON inv.id = ipe.investisseur_id AND inv.user_id = ?
|
||||
WHERE ipe.id = ?
|
||||
`).get(req.user.id, entityId);
|
||||
if (!owned) throw new HttpError(404, 'Investissement PE introuvable');
|
||||
}
|
||||
|
||||
const usage = getUsage(req.user.id);
|
||||
const { quotaBytes, quotaCount } = getQuotaConfig();
|
||||
if (usage.used_count + 1 > quotaCount) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Router } from 'express';
|
||||
import { z } from 'zod';
|
||||
import db from '../db/index.js';
|
||||
import { HttpError } from '../middleware/errorHandler.js';
|
||||
import { resolveActiveWorkspaceId } from '../middleware/workspaceScope.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -16,14 +17,16 @@ const router = Router();
|
||||
* Montant stocké : TTC uniquement (pas de détail HT/TVA, non pertinent pour
|
||||
* un particulier — cf. échange avec Olivier, 30/08/26).
|
||||
*
|
||||
* Étape 1 (fondations) : ce module fournit un CRUD complet, mais rien dans
|
||||
* l'application ne crée encore de frais_operations automatiquement — la
|
||||
* saisie à la souscription, la modale dédiée et l'intégration aux Mouvements
|
||||
* viendront dans une prochaine passe.
|
||||
* Depuis le 17/09/26 (chantier "frais PE"), une ligne se rattache SOIT à un
|
||||
* investissement crowdlending (investissement_id), SOIT à un deal Private
|
||||
* Equity (investissement_pe_id) — jamais les deux (CHECK en base). Le corps
|
||||
* de requête doit donc porter exactement l'un des deux champs ; toutes les
|
||||
* routes ci-dessous se branchent sur celui qui est renseigné.
|
||||
*/
|
||||
|
||||
const Schema = z.object({
|
||||
investissement_id: z.number().int().positive(),
|
||||
investissement_id: z.number().int().positive().nullable().optional(),
|
||||
investissement_pe_id: z.number().int().positive().nullable().optional(),
|
||||
remboursement_id: z.number().int().positive().nullable().optional(),
|
||||
montant: z.number().positive(),
|
||||
mode_reglement: z.enum(['source', 'portefeuille', 'compte_courant']).default('source'),
|
||||
@@ -35,7 +38,10 @@ const Schema = z.object({
|
||||
// "Frais" du formulaire d'investissement (unique par investissement, cf. assertUniqueSouscription).
|
||||
categorie: z.enum(['souscription', 'gestion', 'distribution', 'autre']).default('autre'),
|
||||
notes: z.string().optional(),
|
||||
});
|
||||
}).refine(
|
||||
b => (b.investissement_id != null) !== (b.investissement_pe_id != null),
|
||||
{ message: 'Exactement un de investissement_id ou investissement_pe_id doit être renseigné' },
|
||||
);
|
||||
|
||||
function assertOwnedInvestissement(invId, userId) {
|
||||
const row = db.prepare(`
|
||||
@@ -47,10 +53,31 @@ function assertOwnedInvestissement(invId, userId) {
|
||||
return row;
|
||||
}
|
||||
|
||||
function assertRemboursementBelongs(rembId, invId) {
|
||||
/** Équivalent pour les deals PE (investissements_pe) — bornée au workspace actif, même
|
||||
* principe que ownedDeal() dans investissementsPe.js. */
|
||||
function assertOwnedInvestissementPe(invPeId, req) {
|
||||
const row = db.prepare(`
|
||||
SELECT ipe.id, ipe.plateforme_id FROM investissements_pe ipe
|
||||
JOIN investisseurs inv ON inv.id = ipe.investisseur_id AND inv.user_id = ?
|
||||
WHERE ipe.id = ? AND ipe.workspace_id = ?
|
||||
`).get(req.user.id, invPeId, resolveActiveWorkspaceId(req));
|
||||
if (!row) throw new HttpError(403, 'Investissement PE not in scope');
|
||||
return row;
|
||||
}
|
||||
|
||||
/** Résout et vérifie la référence portée par le corps de requête (crowdlending ou PE),
|
||||
* renvoie { id, plateforme_id } de l'investissement/deal correspondant. */
|
||||
function assertOwnedInvestissementEither(body, req) {
|
||||
return body.investissement_id
|
||||
? assertOwnedInvestissement(body.investissement_id, req.user.id)
|
||||
: assertOwnedInvestissementPe(body.investissement_pe_id, req);
|
||||
}
|
||||
|
||||
function assertRemboursementBelongs(rembId, body) {
|
||||
if (!rembId) return;
|
||||
const remb = db.prepare('SELECT id FROM remboursements WHERE id = ? AND investissement_id = ?')
|
||||
.get(rembId, invId);
|
||||
const remb = body.investissement_id
|
||||
? db.prepare('SELECT id FROM remboursements WHERE id = ? AND investissement_id = ?').get(rembId, body.investissement_id)
|
||||
: db.prepare('SELECT id FROM remboursements WHERE id = ? AND investissement_pe_id = ?').get(rembId, body.investissement_pe_id);
|
||||
if (!remb) throw new HttpError(400, "remboursement_id ne correspond pas à cet investissement");
|
||||
}
|
||||
|
||||
@@ -61,30 +88,34 @@ export function assertOwnedCompte(compteId, userId) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Un investissement ne peut avoir qu'UNE seule entrée de catégorie 'souscription' — c'est
|
||||
* l'entrée lue/écrite depuis l'étape "Frais" du formulaire de création/modification de
|
||||
* l'investissement (jamais depuis le bloc "Frais d'opération" de la fiche, qui exclut cette
|
||||
* catégorie de son sélecteur). `excludeId` : à passer lors d'un PUT pour ne pas se bloquer
|
||||
* soi-même.
|
||||
* Un investissement (ou un deal PE) ne peut avoir qu'UNE seule entrée de catégorie
|
||||
* 'souscription' — c'est l'entrée lue/écrite depuis l'étape "Frais" du formulaire de
|
||||
* création/modification (jamais depuis le bloc "Frais d'opération"/la page Frais, qui
|
||||
* excluent cette catégorie de leur sélecteur). `excludeId` : à passer lors d'un PUT pour
|
||||
* ne pas se bloquer soi-même.
|
||||
*/
|
||||
function assertUniqueSouscription(investissementId, categorie, excludeId) {
|
||||
function assertUniqueSouscription(body, categorie, excludeId) {
|
||||
if (categorie !== 'souscription') return;
|
||||
const col = body.investissement_id ? 'investissement_id' : 'investissement_pe_id';
|
||||
const val = body.investissement_id ?? body.investissement_pe_id;
|
||||
const existing = excludeId
|
||||
? db.prepare(`SELECT id FROM frais_operations WHERE investissement_id = ? AND categorie = 'souscription' AND id != ?`).get(investissementId, excludeId)
|
||||
: db.prepare(`SELECT id FROM frais_operations WHERE investissement_id = ? AND categorie = 'souscription'`).get(investissementId);
|
||||
? db.prepare(`SELECT id FROM frais_operations WHERE ${col} = ? AND categorie = 'souscription' AND id != ?`).get(val, excludeId)
|
||||
: db.prepare(`SELECT id FROM frais_operations WHERE ${col} = ? AND categorie = 'souscription'`).get(val);
|
||||
if (existing) throw new HttpError(409, 'Un frais de catégorie "souscription" existe déjà pour cet investissement');
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/frais-operations?investissement_id=123
|
||||
* Sans investissement_id : tous les frais de l'utilisateur (tous investisseurs), enrichis de
|
||||
* nom_projet/plateforme_nom/investisseur_nom (vue portefeuille, ex. page dédiée Frais — colonne
|
||||
* Détenteur et onglet "Investissements", Étape 11, 02/09/26) — le filtre par investissement_id
|
||||
* n'a pas besoin de cet enrichissement, le contexte est déjà connu côté client.
|
||||
* 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
|
||||
* resolveActiveWorkspaceId, header X-Workspace-Id) — crowdlending ou PE selon le slug
|
||||
* du workspace, enrichis de nom_projet/nom_deal + plateforme_nom/investisseur_nom (vue
|
||||
* portefeuille, ex. page dédiée Frais/FraisPe). Le filtre par investissement_id/
|
||||
* investissement_pe_id n'a pas besoin de cet enrichissement, le contexte est déjà connu
|
||||
* côté client.
|
||||
*/
|
||||
router.get('/', (req, res, next) => {
|
||||
try {
|
||||
const { investissement_id } = req.query;
|
||||
const { investissement_id, investissement_pe_id } = req.query;
|
||||
let rows;
|
||||
if (investissement_id) {
|
||||
assertOwnedInvestissement(Number(investissement_id), req.user.id);
|
||||
@@ -95,7 +126,19 @@ router.get('/', (req, res, next) => {
|
||||
WHERE f.investissement_id = ?
|
||||
ORDER BY f.date_operation DESC, f.id DESC
|
||||
`).all(Number(investissement_id));
|
||||
} else if (investissement_pe_id) {
|
||||
assertOwnedInvestissementPe(Number(investissement_pe_id), req);
|
||||
rows = db.prepare(`
|
||||
SELECT f.*, c.nom AS compte_nom
|
||||
FROM frais_operations f
|
||||
LEFT JOIN comptes c ON c.id = f.compte_id
|
||||
WHERE f.investissement_pe_id = ?
|
||||
ORDER BY f.date_operation DESC, f.id DESC
|
||||
`).all(Number(investissement_pe_id));
|
||||
} else {
|
||||
const workspaceId = resolveActiveWorkspaceId(req);
|
||||
const ws = db.prepare('SELECT slug FROM workspaces WHERE id = ?').get(workspaceId);
|
||||
if (!ws || ws.slug === 'crowdlending') {
|
||||
rows = db.prepare(`
|
||||
SELECT f.*, c.nom AS compte_nom, i.nom_projet, p.nom AS plateforme_nom, inv.nom AS investisseur_nom
|
||||
FROM frais_operations f
|
||||
@@ -106,6 +149,18 @@ router.get('/', (req, res, next) => {
|
||||
WHERE inv.user_id = ?
|
||||
ORDER BY f.date_operation DESC, f.id DESC
|
||||
`).all(req.user.id);
|
||||
} else {
|
||||
rows = db.prepare(`
|
||||
SELECT f.*, c.nom AS compte_nom, ipe.nom_deal, p.nom AS plateforme_nom, inv.nom AS investisseur_nom
|
||||
FROM frais_operations f
|
||||
JOIN investissements_pe ipe ON ipe.id = f.investissement_pe_id
|
||||
JOIN investisseurs inv ON inv.id = ipe.investisseur_id
|
||||
JOIN plateformes p ON p.id = ipe.plateforme_id
|
||||
LEFT JOIN comptes c ON c.id = f.compte_id
|
||||
WHERE inv.user_id = ? AND ipe.workspace_id = ?
|
||||
ORDER BY f.date_operation DESC, f.id DESC
|
||||
`).all(req.user.id, workspaceId);
|
||||
}
|
||||
}
|
||||
res.json(rows);
|
||||
} catch (e) { next(e); }
|
||||
@@ -114,19 +169,19 @@ router.get('/', (req, res, next) => {
|
||||
router.post('/', (req, res, next) => {
|
||||
try {
|
||||
const body = Schema.parse(req.body);
|
||||
const inv = assertOwnedInvestissement(body.investissement_id, req.user.id);
|
||||
assertRemboursementBelongs(body.remboursement_id, body.investissement_id);
|
||||
const inv = assertOwnedInvestissementEither(body, req);
|
||||
assertRemboursementBelongs(body.remboursement_id, body);
|
||||
if (body.mode_reglement === 'compte_courant') assertOwnedCompte(body.compte_id, req.user.id);
|
||||
assertUniqueSouscription(body.investissement_id, body.categorie);
|
||||
assertUniqueSouscription(body, body.categorie);
|
||||
|
||||
const compteId = body.mode_reglement === 'compte_courant' ? (body.compte_id ?? null) : null;
|
||||
|
||||
const r = db.prepare(`
|
||||
INSERT INTO frais_operations
|
||||
(investissement_id, plateforme_id, remboursement_id, montant, mode_reglement, compte_id, date_operation, origine, categorie, notes)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?)
|
||||
(investissement_id, investissement_pe_id, plateforme_id, remboursement_id, montant, mode_reglement, compte_id, date_operation, origine, categorie, notes)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?)
|
||||
`).run(
|
||||
body.investissement_id, inv.plateforme_id, body.remboursement_id ?? null,
|
||||
body.investissement_id ?? null, body.investissement_pe_id ?? null, inv.plateforme_id, body.remboursement_id ?? null,
|
||||
body.montant, body.mode_reglement, compteId,
|
||||
body.date_operation, body.origine, body.categorie, body.notes || null,
|
||||
);
|
||||
@@ -137,29 +192,27 @@ router.post('/', (req, res, next) => {
|
||||
router.put('/:id', (req, res, next) => {
|
||||
try {
|
||||
const id = Number(req.params.id);
|
||||
const existing = db.prepare(`
|
||||
SELECT f.id FROM frais_operations f
|
||||
JOIN investissements i ON i.id = f.investissement_id
|
||||
JOIN investisseurs inv ON inv.id = i.investisseur_id
|
||||
WHERE f.id = ? AND inv.user_id = ?
|
||||
`).get(id, req.user.id);
|
||||
const existing = db.prepare('SELECT id, investissement_id, investissement_pe_id FROM frais_operations WHERE id = ?').get(id);
|
||||
if (!existing) throw new HttpError(404, 'Not found');
|
||||
// Vérifie que l'utilisateur possédait déjà cette ligne, quel que soit son type actuel.
|
||||
if (existing.investissement_id) assertOwnedInvestissement(existing.investissement_id, req.user.id);
|
||||
else assertOwnedInvestissementPe(existing.investissement_pe_id, req);
|
||||
|
||||
const body = Schema.parse(req.body);
|
||||
const inv = assertOwnedInvestissement(body.investissement_id, req.user.id);
|
||||
assertRemboursementBelongs(body.remboursement_id, body.investissement_id);
|
||||
const inv = assertOwnedInvestissementEither(body, req);
|
||||
assertRemboursementBelongs(body.remboursement_id, body);
|
||||
if (body.mode_reglement === 'compte_courant') assertOwnedCompte(body.compte_id, req.user.id);
|
||||
assertUniqueSouscription(body.investissement_id, body.categorie, id);
|
||||
assertUniqueSouscription(body, body.categorie, id);
|
||||
|
||||
const compteId = body.mode_reglement === 'compte_courant' ? (body.compte_id ?? null) : null;
|
||||
|
||||
db.prepare(`
|
||||
UPDATE frais_operations
|
||||
SET investissement_id=?, plateforme_id=?, remboursement_id=?, montant=?, mode_reglement=?,
|
||||
SET investissement_id=?, investissement_pe_id=?, plateforme_id=?, remboursement_id=?, montant=?, mode_reglement=?,
|
||||
compte_id=?, date_operation=?, origine=?, categorie=?, notes=?, updated_at=datetime('now')
|
||||
WHERE id=?
|
||||
`).run(
|
||||
body.investissement_id, inv.plateforme_id, body.remboursement_id ?? null,
|
||||
body.investissement_id ?? null, body.investissement_pe_id ?? null, inv.plateforme_id, body.remboursement_id ?? null,
|
||||
body.montant, body.mode_reglement, compteId,
|
||||
body.date_operation, body.origine, body.categorie, body.notes || null,
|
||||
id,
|
||||
@@ -171,13 +224,10 @@ router.put('/:id', (req, res, next) => {
|
||||
router.delete('/:id', (req, res, next) => {
|
||||
try {
|
||||
const id = Number(req.params.id);
|
||||
const existing = db.prepare(`
|
||||
SELECT f.id FROM frais_operations f
|
||||
JOIN investissements i ON i.id = f.investissement_id
|
||||
JOIN investisseurs inv ON inv.id = i.investisseur_id
|
||||
WHERE f.id = ? AND inv.user_id = ?
|
||||
`).get(id, req.user.id);
|
||||
const existing = db.prepare('SELECT id, investissement_id, investissement_pe_id FROM frais_operations WHERE id = ?').get(id);
|
||||
if (!existing) throw new HttpError(404, 'Not found');
|
||||
if (existing.investissement_id) assertOwnedInvestissement(existing.investissement_id, req.user.id);
|
||||
else assertOwnedInvestissementPe(existing.investissement_pe_id, req);
|
||||
|
||||
db.prepare('DELETE FROM frais_operations WHERE id = ?').run(id);
|
||||
res.status(204).end();
|
||||
|
||||
@@ -9,6 +9,7 @@ xlsx.set_fs(fs);
|
||||
import db from '../db/index.js';
|
||||
import { HttpError } from '../middleware/errorHandler.js';
|
||||
import { requireInvestisseur } from '../middleware/investisseurScope.js';
|
||||
import { resolveActiveWorkspaceId } from '../middleware/workspaceScope.js';
|
||||
import { generateSimul, generateSimulWithReinvestissements, adjustSimulForActuals } from '../utils/schedule.js';
|
||||
import { recordHistory, detectChangements, detectTypeEvenement } from './investissements.js';
|
||||
import { checkDonneesIncompletes } from '../jobs/checkDonneesIncompletes.js';
|
||||
@@ -359,6 +360,9 @@ router.post('/apply', (req, res, next) => {
|
||||
const cur = platMinDate.get(plateformeId);
|
||||
if (!cur || dateStr < cur) platMinDate.set(plateformeId, dateStr);
|
||||
};
|
||||
// Étape 2a du retrofit workspace_id (17/09/26) : résolu une seule fois pour
|
||||
// tout le lot importé (même job = même workspace actif).
|
||||
const workspaceId = resolveActiveWorkspaceId(req);
|
||||
|
||||
const tx = db.transaction(() => {
|
||||
for (let idx = 0; idx < rows.length; idx++) {
|
||||
@@ -394,8 +398,8 @@ router.post('/apply', (req, res, next) => {
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO depots_retraits
|
||||
(investisseur_id, plateforme_id, date_operation, type, montant, libelle, reference, source)
|
||||
VALUES (?,?,?,?,?,?,?,?)
|
||||
(investisseur_id, plateforme_id, date_operation, type, montant, libelle, reference, source, workspace_id)
|
||||
VALUES (?,?,?,?,?,?,?,?,?)
|
||||
`).run(
|
||||
req.investisseur.id,
|
||||
plateformeId,
|
||||
@@ -405,6 +409,7 @@ router.post('/apply', (req, res, next) => {
|
||||
v('libelle') || null,
|
||||
v('reference') || null,
|
||||
srcLabel,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
} else if (module === 'investissements') {
|
||||
@@ -468,14 +473,15 @@ router.post('/apply', (req, res, next) => {
|
||||
db.prepare(`
|
||||
INSERT INTO remboursements
|
||||
(investissement_id, date_remb, capital, cashback, interets_bruts, prelev_sociaux,
|
||||
prelev_forfaitaire, interets_nets, net_recu, statut, source)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?)
|
||||
prelev_forfaitaire, interets_nets, net_recu, statut, source, workspace_id)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
`).run(
|
||||
investissementId,
|
||||
dateRemb,
|
||||
capital, cashback, bruts, ps, pf, interets_nets, net_recu,
|
||||
v('statut') || 'paye',
|
||||
srcLabel,
|
||||
workspaceId,
|
||||
);
|
||||
affectedInvestissementIds.add(investissementId);
|
||||
|
||||
@@ -721,6 +727,9 @@ router.post('/dossier/apply', (req, res, next) => {
|
||||
|
||||
let action, investissementId;
|
||||
let docsInserted = 0, docsSkipped = 0;
|
||||
// Étape 2a du retrofit workspace_id (17/09/26) : résolu une seule fois
|
||||
// pour tout le dossier importé (même job = même workspace actif).
|
||||
const dossierWorkspaceId = resolveActiveWorkspaceId(req);
|
||||
|
||||
const tx = db.transaction(() => {
|
||||
/* ── 1. Résoudre / créer la plateforme ─────────────────── */
|
||||
@@ -768,14 +777,15 @@ router.post('/dossier/apply', (req, res, next) => {
|
||||
db.prepare(`
|
||||
INSERT INTO remboursements
|
||||
(investissement_id, date_remb, capital, cashback, interets_bruts,
|
||||
prelev_sociaux, prelev_forfaitaire, interets_nets, net_recu, statut, notes, source)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?, 'import_dossier')
|
||||
prelev_sociaux, prelev_forfaitaire, interets_nets, net_recu, statut, notes, source, workspace_id)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?, 'import_dossier', ?)
|
||||
`).run(
|
||||
investissementId, rb.date_remb,
|
||||
rb.capital || 0, rb.cashback || 0, rb.interets_bruts || 0,
|
||||
rb.prelev_sociaux || 0, rb.prelev_forfaitaire || 0,
|
||||
rb.interets_nets || 0, rb.net_recu || 0,
|
||||
rb.statut || 'paye', rb.notes || null,
|
||||
dossierWorkspaceId,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -867,14 +877,15 @@ router.post('/dossier/apply', (req, res, next) => {
|
||||
db.prepare(`
|
||||
INSERT INTO remboursements
|
||||
(investissement_id, date_remb, capital, cashback, interets_bruts,
|
||||
prelev_sociaux, prelev_forfaitaire, interets_nets, net_recu, statut, notes, source)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?, 'import_dossier')
|
||||
prelev_sociaux, prelev_forfaitaire, interets_nets, net_recu, statut, notes, source, workspace_id)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?, 'import_dossier', ?)
|
||||
`).run(
|
||||
investissementId, rb.date_remb,
|
||||
rb.capital || 0, rb.cashback || 0, rb.interets_bruts || 0,
|
||||
rb.prelev_sociaux || 0, rb.prelev_forfaitaire || 0,
|
||||
rb.interets_nets || 0, rb.net_recu || 0,
|
||||
rb.statut || 'paye', rb.notes || null,
|
||||
dossierWorkspaceId,
|
||||
);
|
||||
rembInserted++;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
import { Router } from 'express';
|
||||
import { z } from 'zod';
|
||||
import db from '../db/index.js';
|
||||
import { HttpError } from '../middleware/errorHandler.js';
|
||||
import { requireInvestisseur } from '../middleware/investisseurScope.js';
|
||||
import { resolveActiveWorkspaceId } from '../middleware/workspaceScope.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* Investissements Private Equity — table dédiée (17/09/26, cf.
|
||||
* project_workspaces_transformation.md), distincte de `investissements`
|
||||
* (crowdlending) : un deal PE n'a ni échéancier connu à l'avance ni taux
|
||||
* d'intérêt fixe, c'est un montant unique investi, valorisé périodiquement
|
||||
* par le fonds (investissements_pe_valorisations), avec un objectif de
|
||||
* performance hétérogène selon les deals (texte libre plutôt que structuré).
|
||||
*
|
||||
* workspace_id est NOT NULL dès la création (table neuve, pas de legacy à
|
||||
* backfiller) — toutes les lignes appartiennent au workspace actif au
|
||||
* moment de la création, et toute lecture/écriture est bornée au workspace
|
||||
* actif via resolveActiveWorkspaceId (même principe que depots_retraits et
|
||||
* remboursements).
|
||||
*/
|
||||
const Schema = z.object({
|
||||
investisseur_id: z.number().int().positive().optional(),
|
||||
plateforme_id: z.number().int().positive(),
|
||||
nom_deal: z.string().min(1),
|
||||
strategie: z.string().optional(),
|
||||
date_souscription: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||
duree_mois: z.number().int().positive().nullable().optional(),
|
||||
montant_investi: z.number().positive(),
|
||||
objectif: z.string().optional(),
|
||||
statut: z.enum(['en_attente', 'valide', 'cloture', 'perte_definitive']).default('valide'),
|
||||
reference: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
});
|
||||
|
||||
const ValorisationSchema = z.object({
|
||||
date_valorisation: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||
valorisation_nette: z.number().nonnegative(),
|
||||
notes: z.string().optional(),
|
||||
});
|
||||
|
||||
/** Résout l'investisseur_id : body en priorité (validé), sinon header (comme depots_retraits.js) */
|
||||
function resolveInvestisseurId(req, bodyInvestisseurId) {
|
||||
if (!bodyInvestisseurId) return req.investisseur.id;
|
||||
const row = db.prepare('SELECT id FROM investisseurs WHERE id = ? AND user_id = ?')
|
||||
.get(bodyInvestisseurId, req.user.id);
|
||||
if (!row) throw new HttpError(403, 'Investisseur non autorisé');
|
||||
return bodyInvestisseurId;
|
||||
}
|
||||
|
||||
/** Vérifie qu'un deal appartient bien à l'utilisateur courant (et au workspace actif), le renvoie. */
|
||||
function ownedDeal(req, id) {
|
||||
const row = db.prepare(`
|
||||
SELECT ipe.* FROM investissements_pe ipe
|
||||
JOIN investisseurs inv ON inv.id = ipe.investisseur_id
|
||||
WHERE ipe.id = ? AND inv.user_id = ? AND ipe.workspace_id = ?
|
||||
`).get(id, req.user.id, resolveActiveWorkspaceId(req));
|
||||
if (!row) throw new HttpError(404, 'Not found');
|
||||
return row;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/investissements-pe
|
||||
* ?scope=all → agrège tous les investisseurs de l'utilisateur (vue "Famille")
|
||||
* (défaut) → filtre sur l'investisseur donné par X-Investisseur-Id
|
||||
*/
|
||||
router.get('/', (req, res) => {
|
||||
const scopeAll = req.query.scope === 'all';
|
||||
const userId = req.user.id;
|
||||
|
||||
let invCond, invArgs;
|
||||
if (scopeAll) {
|
||||
invCond = 'inv.user_id = ?';
|
||||
invArgs = [userId];
|
||||
} 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, userId);
|
||||
if (!row) return res.status(403).json({ error: 'Investisseur not found or not owned by user' });
|
||||
invCond = 'ipe.investisseur_id = ?';
|
||||
invArgs = [id];
|
||||
}
|
||||
|
||||
const workspaceId = resolveActiveWorkspaceId(req);
|
||||
|
||||
const rows = db.prepare(`
|
||||
SELECT ipe.*,
|
||||
p.nom AS plateforme_nom,
|
||||
inv.nom AS investisseur_nom,
|
||||
(SELECT v.valorisation_nette FROM investissements_pe_valorisations v
|
||||
WHERE v.investissement_pe_id = ipe.id
|
||||
ORDER BY v.date_valorisation DESC, v.id DESC LIMIT 1) AS derniere_valorisation,
|
||||
(SELECT v.date_valorisation FROM investissements_pe_valorisations v
|
||||
WHERE v.investissement_pe_id = ipe.id
|
||||
ORDER BY v.date_valorisation DESC, v.id DESC LIMIT 1) AS derniere_valorisation_date,
|
||||
COALESCE((SELECT SUM(r.net_recu) FROM remboursements r
|
||||
WHERE r.investissement_pe_id = ipe.id), 0) AS distributions_cumulees
|
||||
FROM investissements_pe ipe
|
||||
JOIN plateformes p ON p.id = ipe.plateforme_id
|
||||
JOIN investisseurs inv ON inv.id = ipe.investisseur_id
|
||||
WHERE ${invCond} AND ipe.workspace_id = ?
|
||||
ORDER BY ipe.date_souscription DESC, ipe.id DESC
|
||||
`).all(...invArgs, workspaceId);
|
||||
|
||||
const withTvpi = rows.map(r => ({
|
||||
...r,
|
||||
tvpi: r.derniere_valorisation != null
|
||||
? Math.round(((r.derniere_valorisation + r.distributions_cumulees) / r.montant_investi) * 100) / 100
|
||||
: null,
|
||||
}));
|
||||
|
||||
res.json(withTvpi);
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/investissements-pe/valorisations
|
||||
* Historique complet des valorisations, tous deals confondus (workspace
|
||||
* actif) — nécessaire pour reconstituer une courbe de valorisation du
|
||||
* portefeuille (page Investissements PE : graphique "Valorisation totale"
|
||||
* et onglet "Vision mensuelle"), ce que l'historique par deal seul
|
||||
* (GET /:id/valorisations) ne permet pas sans un aller-retour par deal.
|
||||
* ?scope=all → agrège tous les investisseurs de l'utilisateur (comme GET /).
|
||||
*/
|
||||
router.get('/valorisations', (req, res) => {
|
||||
const scopeAll = req.query.scope === 'all';
|
||||
const userId = req.user.id;
|
||||
|
||||
let invCond, invArgs;
|
||||
if (scopeAll) {
|
||||
invCond = 'inv.user_id = ?';
|
||||
invArgs = [userId];
|
||||
} 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, userId);
|
||||
if (!row) return res.status(403).json({ error: 'Investisseur not found or not owned by user' });
|
||||
invCond = 'ipe.investisseur_id = ?';
|
||||
invArgs = [id];
|
||||
}
|
||||
|
||||
const workspaceId = resolveActiveWorkspaceId(req);
|
||||
|
||||
const rows = db.prepare(`
|
||||
SELECT v.id, v.investissement_pe_id, v.date_valorisation, v.valorisation_nette
|
||||
FROM investissements_pe_valorisations v
|
||||
JOIN investissements_pe ipe ON ipe.id = v.investissement_pe_id
|
||||
JOIN investisseurs inv ON inv.id = ipe.investisseur_id
|
||||
WHERE ${invCond} AND ipe.workspace_id = ?
|
||||
ORDER BY v.date_valorisation ASC, v.id ASC
|
||||
`).all(...invArgs, workspaceId);
|
||||
|
||||
res.json(rows);
|
||||
});
|
||||
|
||||
router.post('/', requireInvestisseur, (req, res, next) => {
|
||||
try {
|
||||
const body = Schema.parse(req.body);
|
||||
const investisseurId = resolveInvestisseurId(req, body.investisseur_id);
|
||||
const workspaceId = resolveActiveWorkspaceId(req);
|
||||
const r = db.prepare(`
|
||||
INSERT INTO investissements_pe
|
||||
(investisseur_id, plateforme_id, workspace_id, nom_deal, strategie, date_souscription,
|
||||
duree_mois, montant_investi, objectif, statut, reference, notes)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
`).run(
|
||||
investisseurId, body.plateforme_id, workspaceId, body.nom_deal, body.strategie || null,
|
||||
body.date_souscription, body.duree_mois ?? null, body.montant_investi, body.objectif || null,
|
||||
body.statut, body.reference || null, body.notes || null,
|
||||
);
|
||||
res.status(201).json({ id: r.lastInsertRowid, ...body });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/investissements-pe/:id — fiche détaillée d'un deal (chantier "fiche détail PE",
|
||||
* 17/09/26). Même enrichissement que GET / (dernière valorisation, distributions cumulées,
|
||||
* TVPI) mais pour une seule ligne — évite de renvoyer toute la liste juste pour afficher une
|
||||
* fiche. requireInvestisseur n'est pas utilisé ici (ownedDeal vérifie déjà la propriété par
|
||||
* user_id + workspace actif, sans dépendre de l'investisseur sélectionné côté client — la fiche
|
||||
* doit rester accessible même si l'utilisateur a changé de sélecteur détenteur entre-temps).
|
||||
*/
|
||||
router.get('/:id', (req, res, next) => {
|
||||
try {
|
||||
const id = Number(req.params.id);
|
||||
ownedDeal(req, id);
|
||||
const row = db.prepare(`
|
||||
SELECT ipe.*,
|
||||
p.nom AS plateforme_nom,
|
||||
inv.nom AS investisseur_nom,
|
||||
(SELECT v.valorisation_nette FROM investissements_pe_valorisations v
|
||||
WHERE v.investissement_pe_id = ipe.id
|
||||
ORDER BY v.date_valorisation DESC, v.id DESC LIMIT 1) AS derniere_valorisation,
|
||||
(SELECT v.date_valorisation FROM investissements_pe_valorisations v
|
||||
WHERE v.investissement_pe_id = ipe.id
|
||||
ORDER BY v.date_valorisation DESC, v.id DESC LIMIT 1) AS derniere_valorisation_date,
|
||||
COALESCE((SELECT SUM(r.net_recu) FROM remboursements r
|
||||
WHERE r.investissement_pe_id = ipe.id), 0) AS distributions_cumulees
|
||||
FROM investissements_pe ipe
|
||||
JOIN plateformes p ON p.id = ipe.plateforme_id
|
||||
JOIN investisseurs inv ON inv.id = ipe.investisseur_id
|
||||
WHERE ipe.id = ?
|
||||
`).get(id);
|
||||
if (!row) throw new HttpError(404, 'Not found');
|
||||
row.tvpi = row.derniere_valorisation != null
|
||||
? Math.round(((row.derniere_valorisation + row.distributions_cumulees) / row.montant_investi) * 100) / 100
|
||||
: null;
|
||||
res.json(row);
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
router.put('/:id', requireInvestisseur, (req, res, next) => {
|
||||
try {
|
||||
const id = Number(req.params.id);
|
||||
ownedDeal(req, id);
|
||||
const body = Schema.parse(req.body);
|
||||
const investisseurId = resolveInvestisseurId(req, body.investisseur_id);
|
||||
db.prepare(`
|
||||
UPDATE investissements_pe
|
||||
SET investisseur_id=?, plateforme_id=?, nom_deal=?, strategie=?, date_souscription=?,
|
||||
duree_mois=?, montant_investi=?, objectif=?, statut=?, reference=?, notes=?,
|
||||
updated_at=datetime('now')
|
||||
WHERE id=?
|
||||
`).run(
|
||||
investisseurId, body.plateforme_id, body.nom_deal, body.strategie || null, body.date_souscription,
|
||||
body.duree_mois ?? null, body.montant_investi, body.objectif || null, body.statut,
|
||||
body.reference || null, body.notes || null, id,
|
||||
);
|
||||
res.json({ id, ...body });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
router.delete('/:id', requireInvestisseur, (req, res, next) => {
|
||||
try {
|
||||
const id = Number(req.params.id);
|
||||
ownedDeal(req, id);
|
||||
// Les distributions déjà saisies (remboursements liés) restent en base,
|
||||
// simplement détachées du deal supprimé — jamais de perte de données
|
||||
// financières pour libérer une contrainte de clé étrangère.
|
||||
db.prepare('UPDATE remboursements SET investissement_pe_id = NULL WHERE investissement_pe_id = ?').run(id);
|
||||
db.prepare('DELETE FROM investissements_pe WHERE id = ?').run(id);
|
||||
res.status(204).end();
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
/* ── Historique des valorisations ────────────────────────────── */
|
||||
|
||||
router.get('/:id/valorisations', requireInvestisseur, (req, res, next) => {
|
||||
try {
|
||||
const id = Number(req.params.id);
|
||||
ownedDeal(req, id);
|
||||
const rows = db.prepare(`
|
||||
SELECT * FROM investissements_pe_valorisations
|
||||
WHERE investissement_pe_id = ?
|
||||
ORDER BY date_valorisation DESC, id DESC
|
||||
`).all(id);
|
||||
res.json(rows);
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
router.post('/:id/valorisations', requireInvestisseur, (req, res, next) => {
|
||||
try {
|
||||
const id = Number(req.params.id);
|
||||
ownedDeal(req, id);
|
||||
const body = ValorisationSchema.parse(req.body);
|
||||
const r = db.prepare(`
|
||||
INSERT INTO investissements_pe_valorisations (investissement_pe_id, date_valorisation, valorisation_nette, notes)
|
||||
VALUES (?,?,?,?)
|
||||
`).run(id, body.date_valorisation, body.valorisation_nette, body.notes || null);
|
||||
res.status(201).json({ id: r.lastInsertRowid, investissement_pe_id: id, ...body });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
router.delete('/:id/valorisations/:valId', requireInvestisseur, (req, res, next) => {
|
||||
try {
|
||||
const id = Number(req.params.id);
|
||||
ownedDeal(req, id);
|
||||
const r = db.prepare(`
|
||||
DELETE FROM investissements_pe_valorisations WHERE id = ? AND investissement_pe_id = ?
|
||||
`).run(Number(req.params.valId), id);
|
||||
if (r.changes === 0) throw new HttpError(404, 'Not found');
|
||||
res.status(204).end();
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -4,6 +4,7 @@ import db from '../db/index.js';
|
||||
import { HttpError } from '../middleware/errorHandler.js';
|
||||
import { createZip, readZip } from '../utils/zip.js';
|
||||
import { deleteDocumentsForEntity } from './documents.js';
|
||||
import { getUserActiveWorkspaceIds } from '../middleware/workspaceScope.js';
|
||||
import multer from 'multer';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
@@ -65,6 +66,8 @@ const Schema = z.object({
|
||||
type_pret_defaut: z.enum(['in_fine', 'amortissable', 'differe']).nullable().optional(),
|
||||
freq_interets_defaut: z.enum(['mensuel', 'trimestriel', 'in_fine']).nullable().optional(),
|
||||
referentiel_id: z.number().int().positive().nullable().optional(),
|
||||
// Espaces de travail auxquels cette plateforme utilisateur est affectée (au moins un requis)
|
||||
workspace_ids: z.array(z.number().int()).min(1, 'Au moins un espace de travail doit être sélectionné'),
|
||||
});
|
||||
|
||||
// Champs hérités du référentiel (comparés pour calculer overridden_fields)
|
||||
@@ -223,6 +226,58 @@ function syncCategories(platId, catIds) {
|
||||
})(platId, catIds);
|
||||
}
|
||||
|
||||
/** Espaces de travail auxquels chaque plateforme UTILISATEUR est affectée */
|
||||
function attachWorkspaces(rows) {
|
||||
if (rows.length === 0) return rows;
|
||||
const ids = rows.map(r => r.id);
|
||||
const links = db.prepare(`
|
||||
SELECT pw.plateforme_id, w.id AS workspace_id, w.slug, w.nom
|
||||
FROM plateforme_workspaces pw
|
||||
JOIN workspaces w ON w.id = pw.workspace_id
|
||||
WHERE pw.plateforme_id IN (${ids.map(() => '?').join(',')})
|
||||
ORDER BY w.ordre, w.id
|
||||
`).all(...ids);
|
||||
const map = {};
|
||||
for (const l of links) {
|
||||
if (!map[l.plateforme_id]) map[l.plateforme_id] = [];
|
||||
map[l.plateforme_id].push({ id: l.workspace_id, slug: l.slug, nom: l.nom });
|
||||
}
|
||||
return rows.map(r => ({ ...r, workspaces: map[r.id] || [] }));
|
||||
}
|
||||
|
||||
/** Espaces de travail auxquels chaque entrée du RÉFÉRENTIEL est affectée (pour filtrer l'éligibilité) */
|
||||
function attachReferentielWorkspaces(rows) {
|
||||
if (rows.length === 0) return rows;
|
||||
const ids = rows.map(r => r.id);
|
||||
const links = db.prepare(`
|
||||
SELECT rw.referentiel_id, w.id AS workspace_id, w.slug, w.nom
|
||||
FROM referentiel_workspaces rw
|
||||
JOIN workspaces w ON w.id = rw.workspace_id
|
||||
WHERE rw.referentiel_id IN (${ids.map(() => '?').join(',')})
|
||||
ORDER BY w.ordre, w.id
|
||||
`).all(...ids);
|
||||
const map = {};
|
||||
for (const l of links) {
|
||||
if (!map[l.referentiel_id]) map[l.referentiel_id] = [];
|
||||
map[l.referentiel_id].push({ id: l.workspace_id, slug: l.slug, nom: l.nom });
|
||||
}
|
||||
return rows.map(r => ({ ...r, workspaces: map[r.id] || [] }));
|
||||
}
|
||||
|
||||
/** Workspaces que l'utilisateur a le droit d'utiliser : accordés par l'admin ET activés par lui-même
|
||||
* (Settings > Mes espaces de travail) — seuls ceux-là sont sélectionnables pour une NOUVELLE
|
||||
* affectation de plateforme. Une affectation déjà existante reste conservée même hors de cet
|
||||
* ensemble (cf. validation dans PUT /:id). */
|
||||
// getUserActiveWorkspaceIds : déplacé le 17/09/26 dans middleware/workspaceScope.js
|
||||
// (réutilisé désormais par depots_retraits/imports/remboursements, cf. import ci-dessus).
|
||||
|
||||
function syncWorkspaces(platId, workspaceIds) {
|
||||
db.prepare('DELETE FROM plateforme_workspaces WHERE plateforme_id = ?').run(platId);
|
||||
if (!workspaceIds || workspaceIds.length === 0) return;
|
||||
const ins = db.prepare('INSERT OR IGNORE INTO plateforme_workspaces (plateforme_id, workspace_id) VALUES (?,?)');
|
||||
for (const id of workspaceIds) ins.run(platId, id);
|
||||
}
|
||||
|
||||
// ── Lecture référentiel (tous users authentifiés) ─────────────────────────
|
||||
router.get('/referentiel-list', (_req, res) => {
|
||||
const rows = db.prepare(`
|
||||
@@ -232,7 +287,9 @@ router.get('/referentiel-list', (_req, res) => {
|
||||
FROM plateformes_referentiel pr
|
||||
ORDER BY pr.nom
|
||||
`).all();
|
||||
res.json(rows);
|
||||
// workspaces attachés pour permettre au frontend de filtrer les entrées éligibles
|
||||
// à l'espace de travail choisi par l'utilisateur lors de l'ajout (PlatPickerModal)
|
||||
res.json(attachReferentielWorkspaces(rows));
|
||||
});
|
||||
|
||||
router.get('/', (req, res) => {
|
||||
@@ -261,16 +318,26 @@ router.get('/', (req, res) => {
|
||||
}));
|
||||
const withCats = attachCategories(req.user.id, enriched);
|
||||
const withCatsInv = attachCategoriesInv(withCats);
|
||||
const withAll = attachSecteursInv(withCatsInv).map(r => {
|
||||
const withStripped = attachSecteursInv(withCatsInv).map(r => {
|
||||
const { excluded_categories_inv_ids, excluded_secteurs_inv_ids, ...rest } = r;
|
||||
return rest;
|
||||
});
|
||||
const withAll = attachWorkspaces(withStripped);
|
||||
res.json(withAll);
|
||||
});
|
||||
|
||||
router.post('/', (req, res, next) => {
|
||||
try {
|
||||
const body = Schema.parse(req.body);
|
||||
|
||||
// On ne peut affecter, à la CRÉATION, qu'à des espaces de travail auxquels on a
|
||||
// accès et qu'on a activés (Settings > Mes espaces de travail).
|
||||
const allowedWsCreate = new Set(getUserActiveWorkspaceIds(req.user.id));
|
||||
const invalidWsCreate = body.workspace_ids.filter(id => !allowedWsCreate.has(id));
|
||||
if (invalidWsCreate.length > 0) {
|
||||
throw new HttpError(400, "Vous ne pouvez affecter cette plateforme qu'aux espaces de travail auxquels vous avez accès et que vous avez activés.");
|
||||
}
|
||||
|
||||
let fiscalite = body.domiciliation === 'FR' ? 'flat_tax' : body.fiscalite;
|
||||
let taux = fiscalite === 'avec_fiscalite_locale' ? (body.taux_fiscalite_locale ?? null) : null;
|
||||
let typeProduitFiscal = body.domiciliation === 'FR' ? (body.type_produit_fiscal ?? '2TT') : '2TT';
|
||||
@@ -305,6 +372,7 @@ router.post('/', (req, res, next) => {
|
||||
);
|
||||
const id = r.lastInsertRowid;
|
||||
syncCategories(id, body.categories);
|
||||
syncWorkspaces(id, body.workspace_ids);
|
||||
res.status(201).json({
|
||||
id, ...body,
|
||||
fiscalite, taux_fiscalite_locale: taux, type_produit_fiscal: typeProduitFiscal,
|
||||
@@ -335,6 +403,7 @@ router.get('/export', (req, res, next) => {
|
||||
`).all(req.user.id);
|
||||
rows = attachCategoriesInv(rows);
|
||||
rows = attachSecteursInv(rows);
|
||||
rows = attachWorkspaces(rows);
|
||||
|
||||
// Attach legacy categories
|
||||
const platIds = rows.map(r => r.id);
|
||||
@@ -377,6 +446,7 @@ router.get('/export', (req, res, next) => {
|
||||
categories: legacyMap[r.id] || [],
|
||||
categories_inv: (r.categories_inv || []).map(c => c.nom),
|
||||
secteurs_inv: (r.secteurs_inv || []).map(s => s.nom),
|
||||
workspace_slugs: (r.workspaces || []).map(w => w.slug),
|
||||
}));
|
||||
|
||||
entries.push({ name: 'data.json', data: JSON.stringify(dataRows, null, 2) });
|
||||
@@ -417,7 +487,8 @@ router.get('/:id/export', (req, res, next) => {
|
||||
if (!p) throw new HttpError(404, 'Plateforme introuvable');
|
||||
|
||||
const [withCatsInv] = attachCategoriesInv([p]);
|
||||
const [withAll] = attachSecteursInv([withCatsInv]);
|
||||
const [withSects] = attachSecteursInv([withCatsInv]);
|
||||
const [withAll] = attachWorkspaces([withSects]);
|
||||
|
||||
const catsLegacy = db.prepare(`
|
||||
SELECT c.nom FROM plateforme_categories pc
|
||||
@@ -443,6 +514,7 @@ router.get('/:id/export', (req, res, next) => {
|
||||
categories: catsLegacy,
|
||||
categories_inv: (withAll.categories_inv || []).map(c => c.nom),
|
||||
secteurs_inv: (withAll.secteurs_inv || []).map(s => s.nom),
|
||||
workspace_slugs: (withAll.workspaces || []).map(w => w.slug),
|
||||
};
|
||||
entries.push({ name: 'data.json', data: JSON.stringify([dataRow], null, 2) });
|
||||
|
||||
@@ -541,6 +613,25 @@ router.post('/import-zip', zipUpload.single('file'), async (req, res, next) => {
|
||||
return ids;
|
||||
}
|
||||
|
||||
// Résoudre des slugs de workspaces vers leurs IDs — restreint à ceux auxquels
|
||||
// l'utilisateur important a effectivement accès (accordé + activé). Un slug
|
||||
// inconnu ou hors d'accès est ignoré ; si la liste résultante est vide (vieux ZIP
|
||||
// sans workspace_slugs, ou accès différent), fallback sur "crowdlending" si accessible,
|
||||
// pour ne jamais importer une plateforme sans aucune affectation.
|
||||
const userAllowedWs = new Set(getUserActiveWorkspaceIds(req.user.id));
|
||||
function resolveWorkspaceIdsForUser(slugs) {
|
||||
const ids = [];
|
||||
for (const slug of (slugs || [])) {
|
||||
const row = db.prepare('SELECT id FROM workspaces WHERE slug = ?').get(slug);
|
||||
if (row && userAllowedWs.has(row.id)) ids.push(row.id);
|
||||
}
|
||||
if (ids.length === 0) {
|
||||
const cl = db.prepare("SELECT id FROM workspaces WHERE slug = 'crowdlending'").get();
|
||||
if (cl && userAllowedWs.has(cl.id)) ids.push(cl.id);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
const imageMap = {};
|
||||
for (const e of zipEntries) {
|
||||
if (e.name.startsWith('logos/') && e.name.length > 6) imageMap[path.basename(e.name)] = e.data;
|
||||
@@ -557,6 +648,7 @@ router.post('/import-zip', zipUpload.single('file'), async (req, res, next) => {
|
||||
const catsInvIds = resolveTagIds(p.categories_inv, 'categories_inv');
|
||||
const sectsInvIds = resolveTagIds(p.secteurs_inv, 'secteurs_inv');
|
||||
const legacyCatIds = resolveLegacyCatIds(p.categories);
|
||||
const wsIds = resolveWorkspaceIdsForUser(p.workspace_slugs);
|
||||
|
||||
const existing = db.prepare('SELECT id FROM plateformes WHERE nom = ? AND user_id = ?').get(p.nom, req.user.id);
|
||||
let platId;
|
||||
@@ -601,6 +693,8 @@ router.post('/import-zip', zipUpload.single('file'), async (req, res, next) => {
|
||||
// Sync secteurs_inv
|
||||
db.prepare('DELETE FROM plateforme_secteurs_inv WHERE plateforme_id = ?').run(platId);
|
||||
for (const id of sectsInvIds) db.prepare('INSERT OR IGNORE INTO plateforme_secteurs_inv (plateforme_id, secteur_id) VALUES (?,?)').run(platId, id);
|
||||
// Sync workspaces
|
||||
syncWorkspaces(platId, wsIds);
|
||||
|
||||
// Images
|
||||
for (const fname of [p.logo_filename, p.icone_filename]) {
|
||||
@@ -627,6 +721,18 @@ router.put('/:id', (req, res, next) => {
|
||||
.get(req.params.id, req.user.id);
|
||||
if (!current) throw new HttpError(404, 'Not found');
|
||||
|
||||
// Validation des workspaces : on ne peut AJOUTER que des workspaces auxquels on a
|
||||
// accès et qu'on a activés (Settings > Mes espaces de travail) ; une affectation déjà
|
||||
// présente reste autorisée même si l'accès a depuis été retiré (jamais retirée
|
||||
// silencieusement par cette route — seul l'utilisateur peut la décocher lui-même).
|
||||
const currentWsIds = db.prepare('SELECT workspace_id FROM plateforme_workspaces WHERE plateforme_id = ?')
|
||||
.all(req.params.id).map(r => r.workspace_id);
|
||||
const allowedWsEdit = new Set([...getUserActiveWorkspaceIds(req.user.id), ...currentWsIds]);
|
||||
const invalidWsEdit = body.workspace_ids.filter(id => !allowedWsEdit.has(id));
|
||||
if (invalidWsEdit.length > 0) {
|
||||
throw new HttpError(400, "Vous ne pouvez affecter cette plateforme qu'aux espaces de travail auxquels vous avez accès et que vous avez activés.");
|
||||
}
|
||||
|
||||
// Calculer overridden_fields si la plateforme est liée à un référentiel
|
||||
let overriddenFields = JSON.parse(current.overridden_fields || '[]');
|
||||
if (current.referentiel_id) {
|
||||
@@ -664,6 +770,7 @@ router.put('/:id', (req, res, next) => {
|
||||
);
|
||||
if (r.changes === 0) throw new HttpError(404, 'Not found');
|
||||
syncCategories(Number(req.params.id), body.categories);
|
||||
syncWorkspaces(Number(req.params.id), body.workspace_ids);
|
||||
res.json({
|
||||
id: Number(req.params.id), ...body,
|
||||
fiscalite, taux_fiscalite_locale: taux, type_produit_fiscal: typeProduitFiscal,
|
||||
|
||||
@@ -20,7 +20,7 @@ const ALLOWED_KEYS = new Set([
|
||||
// Contrairement aux autres clés de ALLOWED_KEYS, la valeur est validée ici
|
||||
// (pas seulement la clé) car une valeur invalide casserait silencieusement
|
||||
// le rendu CSS ([data-color-theme] côté frontend n'a pas de fallback).
|
||||
const COLOR_THEME_VALUES = new Set([
|
||||
export const COLOR_THEME_VALUES = new Set([
|
||||
'classique', 'indigo_teal', 'nuit', 'nature', 'coucher_de_soleil',
|
||||
'violet', 'frais', 'contraste', 'pastel', 'moderne',
|
||||
]);
|
||||
|
||||
@@ -110,6 +110,8 @@ const Schema = z.object({
|
||||
// Nouvelles listes gérées globalement (IDs)
|
||||
categories_inv_ids: z.array(z.number().int()).optional().default([]),
|
||||
secteurs_inv_ids: z.array(z.number().int()).optional().default([]),
|
||||
// Espaces de travail auxquels ce référentiel est affecté (au moins un requis)
|
||||
workspace_ids: z.array(z.number().int()).min(1, 'Au moins un espace de travail doit être sélectionné'),
|
||||
// Notation : tableau de critères
|
||||
notation: z.array(z.object({
|
||||
nom: z.string().min(1),
|
||||
@@ -181,6 +183,25 @@ function attachSecteursInv(rows) {
|
||||
return rows.map(r => ({ ...r, secteurs_inv: map[r.id] || [] }));
|
||||
}
|
||||
|
||||
/** Espaces de travail auxquels le référentiel est affecté */
|
||||
function attachWorkspaces(rows) {
|
||||
if (rows.length === 0) return rows;
|
||||
const ids = rows.map(r => r.id);
|
||||
const links = db.prepare(`
|
||||
SELECT rw.referentiel_id, w.id AS workspace_id, w.slug, w.nom
|
||||
FROM referentiel_workspaces rw
|
||||
JOIN workspaces w ON w.id = rw.workspace_id
|
||||
WHERE rw.referentiel_id IN (${ids.map(() => '?').join(',')})
|
||||
ORDER BY w.ordre, w.id
|
||||
`).all(...ids);
|
||||
const map = {};
|
||||
for (const l of links) {
|
||||
if (!map[l.referentiel_id]) map[l.referentiel_id] = [];
|
||||
map[l.referentiel_id].push({ id: l.workspace_id, slug: l.slug, nom: l.nom });
|
||||
}
|
||||
return rows.map(r => ({ ...r, workspaces: map[r.id] || [] }));
|
||||
}
|
||||
|
||||
/** Critères de notation du référentiel */
|
||||
function attachNotation(rows) {
|
||||
if (rows.length === 0) return rows;
|
||||
@@ -213,7 +234,7 @@ function parsePaysOp(rows) {
|
||||
}
|
||||
|
||||
/** Enregistre catégories, secteurs et notation pour un référentiel */
|
||||
function saveRelations(tx, refId, categories, notation, categoriesInvIds, secteursInvIds) {
|
||||
function saveRelations(tx, refId, categories, notation, categoriesInvIds, secteursInvIds, workspaceIds) {
|
||||
tx(() => {
|
||||
// Legacy string categories
|
||||
db.prepare('DELETE FROM referentiel_categories WHERE referentiel_id = ?').run(refId);
|
||||
@@ -238,6 +259,13 @@ function saveRelations(tx, refId, categories, notation, categoriesInvIds, secteu
|
||||
for (const id of secteursInvIds) ins.run(refId, id);
|
||||
}
|
||||
|
||||
// Espaces de travail (IDs) — au moins un requis, déjà validé par le schéma Zod
|
||||
db.prepare('DELETE FROM referentiel_workspaces WHERE referentiel_id = ?').run(refId);
|
||||
if (workspaceIds && workspaceIds.length > 0) {
|
||||
const ins = db.prepare('INSERT OR IGNORE INTO referentiel_workspaces (referentiel_id, workspace_id) VALUES (?,?)');
|
||||
for (const id of workspaceIds) ins.run(refId, id);
|
||||
}
|
||||
|
||||
db.prepare('DELETE FROM referentiel_notation WHERE referentiel_id = ?').run(refId);
|
||||
if (notation.length > 0) {
|
||||
const ins = db.prepare(`
|
||||
@@ -267,6 +295,7 @@ router.get('/', (_req, res) => {
|
||||
rows = attachCats(rows);
|
||||
rows = attachCatsInv(rows);
|
||||
rows = attachSecteursInv(rows);
|
||||
rows = attachWorkspaces(rows);
|
||||
rows = attachNotation(rows);
|
||||
rows = parsePaysOp(rows);
|
||||
res.json(rows);
|
||||
@@ -288,6 +317,7 @@ router.get('/export', (req, res, next) => {
|
||||
let rows = db.prepare('SELECT * FROM plateformes_referentiel ORDER BY nom').all();
|
||||
rows = attachCatsInv(rows);
|
||||
rows = attachSecteursInv(rows);
|
||||
rows = attachWorkspaces(rows);
|
||||
rows = parsePaysOp(rows);
|
||||
|
||||
const entries = [];
|
||||
@@ -353,6 +383,7 @@ router.get('/export', (req, res, next) => {
|
||||
icone_filename: r.icone_filename,
|
||||
categories_inv: (r.categories_inv || []).map(c => c.nom),
|
||||
secteurs_inv: (r.secteurs_inv || []).map(s => s.nom),
|
||||
workspace_slugs: (r.workspaces || []).map(w => w.slug),
|
||||
notation: notationByRef[r.id] || [],
|
||||
}));
|
||||
|
||||
@@ -388,7 +419,7 @@ router.get('/:id/export', (req, res, next) => {
|
||||
try {
|
||||
const ref = db.prepare('SELECT * FROM plateformes_referentiel WHERE id = ?').get(req.params.id);
|
||||
if (!ref) throw new HttpError(404, 'Référentiel introuvable');
|
||||
const [enriched] = parsePaysOp(attachSecteursInv(attachCatsInv([ref])));
|
||||
const [enriched] = parsePaysOp(attachWorkspaces(attachSecteursInv(attachCatsInv([ref]))));
|
||||
|
||||
const entries = [];
|
||||
const manifest = {
|
||||
@@ -450,6 +481,7 @@ router.get('/:id/export', (req, res, next) => {
|
||||
icone_filename: enriched.icone_filename,
|
||||
categories_inv: (enriched.categories_inv || []).map(c => c.nom),
|
||||
secteurs_inv: (enriched.secteurs_inv || []).map(s => s.nom),
|
||||
workspace_slugs: (enriched.workspaces || []).map(w => w.slug),
|
||||
notation,
|
||||
};
|
||||
|
||||
@@ -506,6 +538,25 @@ router.post('/import-zip', zipUpload.single('file'), async (req, res, next) => {
|
||||
return ids;
|
||||
}
|
||||
|
||||
// Résout des slugs de workspaces vers leurs IDs DANS CE catalogue —
|
||||
// contrairement aux catégories/secteurs, les workspaces sont un
|
||||
// catalogue géré par l'admin, jamais créés automatiquement à l'import.
|
||||
// Si aucun slug ne correspond (vieux ZIP sans workspace_slugs, ou
|
||||
// catalogue cible différent), on retombe sur "crowdlending" pour ne
|
||||
// jamais importer une entrée référentiel sans aucune affectation.
|
||||
function resolveWorkspaceIds(slugs) {
|
||||
const ids = [];
|
||||
for (const slug of (slugs || [])) {
|
||||
const row = db.prepare('SELECT id FROM workspaces WHERE slug = ?').get(slug);
|
||||
if (row) ids.push(row.id);
|
||||
}
|
||||
if (ids.length === 0) {
|
||||
const cl = db.prepare("SELECT id FROM workspaces WHERE slug = 'crowdlending'").get();
|
||||
if (cl) ids.push(cl.id);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
// Build a map of images from the ZIP
|
||||
const imageMap = {};
|
||||
for (const e of zipEntries) {
|
||||
@@ -522,6 +573,7 @@ router.post('/import-zip', zipUpload.single('file'), async (req, res, next) => {
|
||||
|
||||
const catsIds = resolveTagIds(p.categories_inv, 'categories_inv');
|
||||
const sectsIds = resolveTagIds(p.secteurs_inv, 'secteurs_inv');
|
||||
const wsIds = resolveWorkspaceIds(p.workspace_slugs);
|
||||
const paysOp = Array.isArray(p.pays_operation) && p.pays_operation.length ? JSON.stringify(p.pays_operation) : null;
|
||||
|
||||
const existing = db.prepare('SELECT id FROM plateformes_referentiel WHERE nom = ?').get(p.nom);
|
||||
@@ -590,6 +642,11 @@ router.post('/import-zip', zipUpload.single('file'), async (req, res, next) => {
|
||||
for (const id of sectsIds) {
|
||||
db.prepare('INSERT OR IGNORE INTO referentiel_secteurs_inv (referentiel_id, secteur_id) VALUES (?,?)').run(refId, id);
|
||||
}
|
||||
// Sync referentiel_workspaces
|
||||
db.prepare('DELETE FROM referentiel_workspaces WHERE referentiel_id = ?').run(refId);
|
||||
for (const id of wsIds) {
|
||||
db.prepare('INSERT OR IGNORE INTO referentiel_workspaces (referentiel_id, workspace_id) VALUES (?,?)').run(refId, id);
|
||||
}
|
||||
|
||||
// Sync referentiel_notation
|
||||
if (Array.isArray(p.notation) && p.notation.length) {
|
||||
@@ -659,7 +716,7 @@ router.get('/:id', (req, res, next) => {
|
||||
try {
|
||||
const row = db.prepare('SELECT * FROM plateformes_referentiel WHERE id = ?').get(req.params.id);
|
||||
if (!row) throw new HttpError(404, 'Référentiel introuvable');
|
||||
const [enriched] = parsePaysOp(attachNotation(attachSecteursInv(attachCatsInv(attachCats([row])))));
|
||||
const [enriched] = parsePaysOp(attachWorkspaces(attachNotation(attachSecteursInv(attachCatsInv(attachCats([row]))))));
|
||||
res.json(enriched);
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
@@ -701,10 +758,10 @@ router.post('/', (req, res, next) => {
|
||||
|
||||
const refId = r.lastInsertRowid;
|
||||
const tx = db.transaction(fn => fn());
|
||||
saveRelations(tx, refId, data.categories, data.notation, data.categories_inv_ids, data.secteurs_inv_ids);
|
||||
saveRelations(tx, refId, data.categories, data.notation, data.categories_inv_ids, data.secteurs_inv_ids, data.workspace_ids);
|
||||
|
||||
const row = db.prepare('SELECT * FROM plateformes_referentiel WHERE id = ?').get(refId);
|
||||
const [enriched] = parsePaysOp(attachNotation(attachSecteursInv(attachCatsInv(attachCats([row])))));
|
||||
const [enriched] = parsePaysOp(attachWorkspaces(attachNotation(attachSecteursInv(attachCatsInv(attachCats([row]))))));
|
||||
enriched.nb_plateformes_liees = 0;
|
||||
res.status(201).json(enriched);
|
||||
} catch (e) { next(e); }
|
||||
@@ -755,11 +812,11 @@ router.put('/:id', (req, res, next) => {
|
||||
);
|
||||
|
||||
const tx = db.transaction(fn => fn());
|
||||
saveRelations(tx, row.id, data.categories, data.notation, data.categories_inv_ids, data.secteurs_inv_ids);
|
||||
saveRelations(tx, row.id, data.categories, data.notation, data.categories_inv_ids, data.secteurs_inv_ids, data.workspace_ids);
|
||||
|
||||
const updated = db.prepare('SELECT * FROM plateformes_referentiel WHERE id = ?').get(row.id);
|
||||
const nb = db.prepare('SELECT COUNT(*) AS n FROM plateformes WHERE referentiel_id = ?').get(row.id).n;
|
||||
const [enriched] = parsePaysOp(attachNotation(attachSecteursInv(attachCatsInv(attachCats([updated])))));
|
||||
const [enriched] = parsePaysOp(attachWorkspaces(attachNotation(attachSecteursInv(attachCatsInv(attachCats([updated]))))));
|
||||
enriched.nb_plateformes_liees = nb;
|
||||
res.json(enriched);
|
||||
} catch (e) { next(e); }
|
||||
|
||||
@@ -3,6 +3,7 @@ import { z } from 'zod';
|
||||
import db from '../db/index.js';
|
||||
import { HttpError } from '../middleware/errorHandler.js';
|
||||
import { requireInvestisseur } from '../middleware/investisseurScope.js';
|
||||
import { resolveActiveWorkspaceId } from '../middleware/workspaceScope.js';
|
||||
import { adjustSimulForActuals, generateSimulWithReinvestissements } from '../utils/schedule.js';
|
||||
import { assertOwnedCompte } from './fraisOperations.js';
|
||||
|
||||
@@ -13,6 +14,12 @@ const Schema = z.object({
|
||||
investissement_id: z.number().int().positive().nullable().optional(),
|
||||
bonus_plateforme_id: z.number().int().positive().nullable().optional(),
|
||||
bonus_investisseur_id: z.number().int().positive().nullable().optional(),
|
||||
// Lien optionnel vers un deal PE (investissements_pe, 17/09/26) : permet de
|
||||
// rattacher une distribution bonus_plateforme/interets_plateforme à un deal
|
||||
// précis pour calculer son TVPI, quand plusieurs deals partagent une même
|
||||
// plateforme (ex. Fundora) — cf. project_workspaces_transformation.md.
|
||||
// Sans effet sur le type 'normal' (crowdlending).
|
||||
investissement_pe_id: z.number().int().positive().nullable().optional(),
|
||||
date_remb: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||
capital: z.number().nonnegative().default(0),
|
||||
cashback: z.number().nonnegative().default(0),
|
||||
@@ -89,7 +96,7 @@ function getTaxIndicatif(body) {
|
||||
* puis en insère un nouveau avec source='auto_remboursement'.
|
||||
* - Sinon : supprime simplement l'ancien retrait lié.
|
||||
*/
|
||||
function syncAutoRetrait(rembId, methode, netRecu, investissementId, dateRemb) {
|
||||
function syncAutoRetrait(rembId, methode, netRecu, investissementId, dateRemb, req) {
|
||||
// Suppression du retrait automatique précédent (s'il y en avait un)
|
||||
db.prepare('DELETE FROM depots_retraits WHERE remboursement_id = ?').run(rembId);
|
||||
|
||||
@@ -100,16 +107,22 @@ function syncAutoRetrait(rembId, methode, netRecu, investissementId, dateRemb) {
|
||||
).get(investissementId);
|
||||
if (!inv) return;
|
||||
|
||||
// Étape 2a du retrofit workspace_id (17/09/26) : le retrait auto généré par un
|
||||
// remboursement hérite du workspace actif au moment du remboursement (même
|
||||
// résolution que les dépôts/retraits manuels, cf. workspaceScope.js).
|
||||
const workspaceId = resolveActiveWorkspaceId(req);
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO depots_retraits
|
||||
(investisseur_id, plateforme_id, date_operation, type, montant,
|
||||
libelle, source, remboursement_id)
|
||||
VALUES (?, ?, ?, 'retrait', ?, ?, 'auto_remboursement', ?)
|
||||
libelle, source, remboursement_id, workspace_id)
|
||||
VALUES (?, ?, ?, 'retrait', ?, ?, 'auto_remboursement', ?, ?)
|
||||
`).run(
|
||||
inv.investisseur_id, inv.plateforme_id, dateRemb,
|
||||
netRecu,
|
||||
`Remboursement — ${inv.nom_projet}`,
|
||||
rembId,
|
||||
workspaceId,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -201,6 +214,9 @@ router.get('/', (req, res) => {
|
||||
: `(i.investisseur_id = ?
|
||||
OR (r.investissement_id IS NULL AND r.bonus_investisseur_id = ?))`;
|
||||
const invArg = scopeAll ? req.user.id : req.investisseur.id;
|
||||
// Étape 2c du retrofit workspace_id (17/09/26) : filtre par workspace actif,
|
||||
// même principe que GET /depots-retraits.
|
||||
const workspaceId = resolveActiveWorkspaceId(req);
|
||||
|
||||
const outerConds = [];
|
||||
const outerArgs = [];
|
||||
@@ -228,7 +244,7 @@ router.get('/', (req, res) => {
|
||||
END AS capital_restant_du
|
||||
FROM remboursements r
|
||||
LEFT JOIN investissements i ON i.id = r.investissement_id
|
||||
WHERE ${invCond}
|
||||
WHERE ${invCond} AND r.workspace_id = ?
|
||||
)
|
||||
SELECT
|
||||
b.*,
|
||||
@@ -254,7 +270,7 @@ router.get('/', (req, res) => {
|
||||
LEFT JOIN comptes c ON c.id = b.compte_id
|
||||
${outerWhere}
|
||||
ORDER BY b.date_remb DESC, b.id DESC
|
||||
`).all(invArg, invArg, ...outerArgs);
|
||||
`).all(invArg, invArg, workspaceId, ...outerArgs);
|
||||
|
||||
res.json(rows);
|
||||
});
|
||||
@@ -271,18 +287,19 @@ router.post('/', (req, res, next) => {
|
||||
INSERT INTO remboursements
|
||||
(type, investissement_id, date_remb, capital, cashback,
|
||||
interets_bruts_avant_local, taxe_locale, interets_bruts, frais_ttc, prelev_sociaux,
|
||||
prelev_forfaitaire, interets_nets, net_recu, statut, source, notes, methode_remboursement, compte_id)
|
||||
VALUES ('normal',?,?,?,?,?,?,?,?,?,?,?,?,?, 'manuel', ?, ?, ?)
|
||||
prelev_forfaitaire, interets_nets, net_recu, statut, source, notes, methode_remboursement, compte_id, workspace_id)
|
||||
VALUES ('normal',?,?,?,?,?,?,?,?,?,?,?,?,?, 'manuel', ?, ?, ?, ?)
|
||||
`).run(
|
||||
body.investissement_id, body.date_remb, body.capital, body.cashback,
|
||||
body.interets_bruts_avant_local, body.taxe_locale,
|
||||
body.interets_bruts, body.frais_ttc, body.prelev_sociaux, body.prelev_forfaitaire,
|
||||
interets_nets, net_recu, body.statut, body.notes || null, body.methode_remboursement,
|
||||
body.methode_remboursement === 'compte_courant' ? (body.compte_id ?? null) : null,
|
||||
resolveActiveWorkspaceId(req),
|
||||
);
|
||||
syncInvestissementStatut(body.investissement_id);
|
||||
adjustSimulForActuals(db, body.investissement_id);
|
||||
syncAutoRetrait(r.lastInsertRowid, body.methode_remboursement, net_recu, body.investissement_id, body.date_remb);
|
||||
syncAutoRetrait(r.lastInsertRowid, body.methode_remboursement, net_recu, body.investissement_id, body.date_remb, req);
|
||||
{
|
||||
const invPlat = db.prepare('SELECT plateforme_id FROM investissements WHERE id = ?').get(body.investissement_id);
|
||||
syncFraisDistribution(
|
||||
@@ -320,13 +337,14 @@ router.post('/', (req, res, next) => {
|
||||
INSERT INTO remboursements
|
||||
(type, bonus_plateforme_id, bonus_investisseur_id, date_remb,
|
||||
capital, cashback, interets_bruts_avant_local, taxe_locale, interets_bruts, frais_ttc,
|
||||
prelev_sociaux, prelev_forfaitaire, interets_nets, net_recu, statut, source, notes)
|
||||
VALUES ('interets_plateforme',?,?,?, 0,0,?,?,?,?,?,?, ?,?, ?, 'manuel', ?)
|
||||
prelev_sociaux, prelev_forfaitaire, interets_nets, net_recu, statut, source, notes, workspace_id, investissement_pe_id)
|
||||
VALUES ('interets_plateforme',?,?,?, 0,0,?,?,?,?,?,?, ?,?, ?, 'manuel', ?, ?, ?)
|
||||
`).run(
|
||||
body.bonus_plateforme_id, bonusInvestisseurId, body.date_remb,
|
||||
body.interets_bruts_avant_local, body.taxe_locale, body.interets_bruts, body.frais_ttc,
|
||||
body.prelev_sociaux, body.prelev_forfaitaire,
|
||||
interets_nets, net_recu, body.statut, body.notes || null,
|
||||
resolveActiveWorkspaceId(req), body.investissement_pe_id ?? null,
|
||||
);
|
||||
return res.status(201).json({ id: r.lastInsertRowid, ...body, interets_nets, net_recu });
|
||||
}
|
||||
@@ -335,11 +353,12 @@ router.post('/', (req, res, next) => {
|
||||
INSERT INTO remboursements
|
||||
(type, bonus_plateforme_id, bonus_investisseur_id, date_remb,
|
||||
capital, cashback, interets_bruts, prelev_sociaux, prelev_forfaitaire,
|
||||
interets_nets, net_recu, statut, source, notes)
|
||||
VALUES (?,?,?,?, 0,?,0,0,0, 0,?, ?, 'manuel', ?)
|
||||
interets_nets, net_recu, statut, source, notes, workspace_id, investissement_pe_id)
|
||||
VALUES (?,?,?,?, 0,?,0,0,0, 0,?, ?, 'manuel', ?, ?, ?)
|
||||
`).run(
|
||||
body.type, body.bonus_plateforme_id, bonusInvestisseurId, body.date_remb,
|
||||
body.cashback, body.cashback, body.statut, body.notes || null,
|
||||
resolveActiveWorkspaceId(req), body.investissement_pe_id ?? null,
|
||||
);
|
||||
res.status(201).json({ id: r.lastInsertRowid, ...body, interets_nets: 0, net_recu: body.cashback });
|
||||
} catch (e) { next(e); }
|
||||
@@ -481,7 +500,7 @@ router.put('/:id', (req, res, next) => {
|
||||
);
|
||||
syncInvestissementStatut(body.investissement_id);
|
||||
adjustSimulForActuals(db, body.investissement_id);
|
||||
syncAutoRetrait(id, body.methode_remboursement, net_recu, body.investissement_id, body.date_remb);
|
||||
syncAutoRetrait(id, body.methode_remboursement, net_recu, body.investissement_id, body.date_remb, req);
|
||||
{
|
||||
const invPlat = db.prepare('SELECT plateforme_id FROM investissements WHERE id = ?').get(body.investissement_id);
|
||||
syncFraisDistribution(
|
||||
@@ -493,20 +512,21 @@ router.put('/:id', (req, res, next) => {
|
||||
db.prepare(`
|
||||
UPDATE remboursements
|
||||
SET date_remb=?, interets_bruts_avant_local=?, taxe_locale=?, interets_bruts=?, frais_ttc=?,
|
||||
prelev_sociaux=?, prelev_forfaitaire=?, interets_nets=?, net_recu=?, statut=?, notes=?
|
||||
prelev_sociaux=?, prelev_forfaitaire=?, interets_nets=?, net_recu=?, statut=?, notes=?,
|
||||
investissement_pe_id=?
|
||||
WHERE id=?
|
||||
`).run(
|
||||
body.date_remb, body.interets_bruts_avant_local, body.taxe_locale, body.interets_bruts, body.frais_ttc,
|
||||
body.prelev_sociaux, body.prelev_forfaitaire,
|
||||
interets_nets, net_recu, body.statut, body.notes || null, id,
|
||||
interets_nets, net_recu, body.statut, body.notes || null, body.investissement_pe_id ?? null, id,
|
||||
);
|
||||
} else {
|
||||
const bonusInvestisseurId = body.bonus_investisseur_id ?? existing.bonus_investisseur_id;
|
||||
db.prepare(`
|
||||
UPDATE remboursements
|
||||
SET date_remb=?, cashback=?, net_recu=?, statut=?, notes=?
|
||||
SET date_remb=?, cashback=?, net_recu=?, statut=?, notes=?, investissement_pe_id=?
|
||||
WHERE id=?
|
||||
`).run(body.date_remb, body.cashback, body.cashback, body.statut, body.notes || null, id);
|
||||
`).run(body.date_remb, body.cashback, body.cashback, body.statut, body.notes || null, body.investissement_pe_id ?? null, id);
|
||||
}
|
||||
|
||||
res.json({ id, ...body, interets_nets, net_recu });
|
||||
@@ -526,7 +546,7 @@ router.delete('/:id', (req, res, next) => {
|
||||
`).get(id, req.user.id);
|
||||
if (!existing) throw new HttpError(404, 'Not found');
|
||||
|
||||
syncAutoRetrait(id, 'portefeuille', 0, null, null); // supprime le retrait auto si présent
|
||||
syncAutoRetrait(id, 'portefeuille', 0, null, null, req); // supprime le retrait auto si présent
|
||||
db.prepare('DELETE FROM remboursements WHERE id=?').run(id);
|
||||
if (existing.investissement_id) {
|
||||
syncInvestissementStatut(existing.investissement_id);
|
||||
@@ -545,7 +565,7 @@ router.delete('/', (req, res, next) => {
|
||||
|
||||
const rembs = db.prepare('SELECT id FROM remboursements WHERE investissement_id=?').all(investissement_id);
|
||||
for (const r of rembs) {
|
||||
syncAutoRetrait(r.id, 'portefeuille', 0, null, null);
|
||||
syncAutoRetrait(r.id, 'portefeuille', 0, null, null, req);
|
||||
}
|
||||
db.prepare('DELETE FROM remboursements WHERE investissement_id=?').run(investissement_id);
|
||||
syncInvestissementStatut(investissement_id);
|
||||
|
||||
@@ -10,8 +10,17 @@ const router = Router();
|
||||
* summary: Liste des mouvements de cash (dépôts / retraits)
|
||||
* tags: [Dépôts / Retraits]
|
||||
* security: [{ ApiKeyAuth: [] }]
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: workspace
|
||||
* schema: { type: string }
|
||||
* description: >
|
||||
* Slug du workspace à filtrer (ex. "crowdlending"). Absent par défaut :
|
||||
* renvoie tous les workspaces confondus (les clés API restent globales,
|
||||
* cf. project_workspaces_transformation.md) — 400 si le slug est inconnu.
|
||||
* responses:
|
||||
* 200: { description: Liste des mouvements }
|
||||
* 400: { description: Slug de workspace inconnu }
|
||||
*/
|
||||
router.get('/', (req, res) => {
|
||||
// Clé "Famille et entreprises" (scope_all) → tous les investisseurs du
|
||||
@@ -21,14 +30,29 @@ router.get('/', (req, res) => {
|
||||
: 'dr.investisseur_id = ?';
|
||||
const invParam = req.investisseurScopeAll ? req.userId : req.investisseurId;
|
||||
|
||||
const conds = [invCond];
|
||||
const args = [invParam];
|
||||
|
||||
// Filtre optionnel par workspace (17/09/26, décision explicite d'Olivier) :
|
||||
// absent par défaut → comportement inchangé, tous workspaces confondus (les
|
||||
// clés API/MCP restent globales, décision du 08/09/26) ; ?workspace=<slug>
|
||||
// permet à un outil MCP de cibler un workspace précis — slug plutôt qu'un id
|
||||
// brut, portable entre installations comme pour l'export/import ZIP.
|
||||
if (req.query.workspace) {
|
||||
const ws = db.prepare('SELECT id FROM workspaces WHERE slug = ?').get(String(req.query.workspace));
|
||||
if (!ws) return res.status(400).json({ error: `Workspace inconnu : "${req.query.workspace}"` });
|
||||
conds.push('dr.workspace_id = ?');
|
||||
args.push(ws.id);
|
||||
}
|
||||
|
||||
const rows = db.prepare(`
|
||||
SELECT dr.id, dr.date_operation, p.nom AS plateforme_nom, dr.type,
|
||||
dr.montant, dr.libelle
|
||||
FROM depots_retraits dr
|
||||
JOIN plateformes p ON p.id = dr.plateforme_id
|
||||
WHERE ${invCond}
|
||||
WHERE ${conds.join(' AND ')}
|
||||
ORDER BY dr.date_operation DESC
|
||||
`).all(invParam);
|
||||
`).all(...args);
|
||||
res.json(rows);
|
||||
});
|
||||
|
||||
|
||||
@@ -23,8 +23,16 @@ const router = Router();
|
||||
* - in: query
|
||||
* name: date_fin
|
||||
* schema: { type: string, format: date }
|
||||
* - in: query
|
||||
* name: workspace
|
||||
* schema: { type: string }
|
||||
* description: >
|
||||
* Slug du workspace à filtrer (ex. "crowdlending"). Absent par défaut :
|
||||
* renvoie tous les workspaces confondus (les clés API restent globales,
|
||||
* cf. project_workspaces_transformation.md) — 400 si le slug est inconnu.
|
||||
* responses:
|
||||
* 200: { description: Liste des remboursements }
|
||||
* 400: { description: Slug de workspace inconnu }
|
||||
*/
|
||||
router.get('/', (req, res) => {
|
||||
const { date_debut, date_fin } = req.query;
|
||||
@@ -37,6 +45,15 @@ router.get('/', (req, res) => {
|
||||
if (date_debut) { conds.push('r.date_remb >= ?'); args.push(date_debut); }
|
||||
if (date_fin) { conds.push('r.date_remb <= ?'); args.push(date_fin); }
|
||||
|
||||
// Filtre optionnel par workspace (17/09/26, même principe que /depots-retraits) :
|
||||
// absent par défaut → comportement inchangé, tous workspaces confondus.
|
||||
if (req.query.workspace) {
|
||||
const ws = db.prepare('SELECT id FROM workspaces WHERE slug = ?').get(String(req.query.workspace));
|
||||
if (!ws) return res.status(400).json({ error: `Workspace inconnu : "${req.query.workspace}"` });
|
||||
conds.push('r.workspace_id = ?');
|
||||
args.push(ws.id);
|
||||
}
|
||||
|
||||
const rows = db.prepare(`
|
||||
SELECT r.id, r.date_remb, i.nom_projet, p.nom AS plateforme_nom,
|
||||
r.capital, r.interets_bruts, r.frais_ttc,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Router } from 'express';
|
||||
import { z } from 'zod';
|
||||
import db from '../db/index.js';
|
||||
import { HttpError } from '../middleware/errorHandler.js';
|
||||
import { COLOR_THEME_VALUES } from './preferences.js';
|
||||
|
||||
/* ── Espaces de travail de l'utilisateur connecté ─────────────────────────
|
||||
* Liste les workspaces accordés par l'admin (granted_by_admin=1) avec leur
|
||||
@@ -14,7 +15,7 @@ const router = Router();
|
||||
|
||||
router.get('/', (req, res) => {
|
||||
const rows = db.prepare(`
|
||||
SELECT w.id, w.slug, w.nom, w.libelle_menu, w.description, uw.active_by_user
|
||||
SELECT w.id, w.slug, w.nom, w.libelle_menu, w.description, uw.active_by_user, uw.color_theme
|
||||
FROM user_workspaces uw
|
||||
JOIN workspaces w ON w.id = uw.workspace_id
|
||||
WHERE uw.user_id = ? AND uw.granted_by_admin = 1 AND w.actif_global = 1
|
||||
@@ -43,4 +44,34 @@ router.patch('/:id/active', (req, res, next) => {
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
// Couleur d'accent personnelle pour cet espace de travail (17/09/26) — cf.
|
||||
// project_workspaces_transformation.md et migration user_workspaces.color_theme
|
||||
// (db/index.js). null = hérite de la couleur de l'application (UiContext.jsx,
|
||||
// colorTheme || adminColorTheme). Même schéma de validation que PATCH
|
||||
// /preferences (color_theme) : valeur vérifiée ici (pas seulement la clé),
|
||||
// une valeur invalide casserait silencieusement le rendu CSS.
|
||||
const ColorSchema = z.object({ color_theme: z.string().nullable() });
|
||||
|
||||
router.patch('/:id/color', (req, res, next) => {
|
||||
try {
|
||||
const { color_theme } = ColorSchema.parse(req.body);
|
||||
const workspaceId = Number(req.params.id);
|
||||
|
||||
if (color_theme !== null && !COLOR_THEME_VALUES.has(color_theme)) {
|
||||
throw new HttpError(400, `Valeur invalide pour color_theme : ${color_theme}`);
|
||||
}
|
||||
|
||||
const grant = db.prepare(
|
||||
'SELECT 1 FROM user_workspaces WHERE user_id = ? AND workspace_id = ? AND granted_by_admin = 1'
|
||||
).get(req.user.id, workspaceId);
|
||||
if (!grant) throw new HttpError(403, "Vous n'avez pas accès à cet espace de travail");
|
||||
|
||||
db.prepare(
|
||||
'UPDATE user_workspaces SET color_theme = ? WHERE user_id = ? AND workspace_id = ?'
|
||||
).run(color_theme, req.user.id, workspaceId);
|
||||
|
||||
res.json({ id: workspaceId, color_theme });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -15,9 +15,11 @@ import plateformesRouter from './routes/plateformes.js';
|
||||
import categoriesRouter from './routes/categories.js';
|
||||
import depotsRetraitsRouter from './routes/depotsRetraits.js';
|
||||
import investissementsRouter from './routes/investissements.js';
|
||||
import investissementsPeRouter from './routes/investissementsPe.js';
|
||||
import remboursementsRouter from './routes/remboursements.js';
|
||||
import simulRouter from './routes/simul.js';
|
||||
import dashboardRouter from './routes/dashboard.js';
|
||||
import dashboardPeRouter from './routes/dashboardPe.js';
|
||||
import taxreportRouter from './routes/taxreport.js';
|
||||
import platefomeTaxRouter from './routes/plateforme-tax.js';
|
||||
import importsRouter from './routes/imports.js';
|
||||
@@ -135,9 +137,11 @@ app.use('/api/plateformes', requireAuth, plateformesRouter);
|
||||
app.use('/api/categories', requireAuth, categoriesRouter);
|
||||
app.use('/api/depots-retraits', requireAuth, depotsRetraitsRouter);
|
||||
app.use('/api/investissements', requireAuth, investissementsRouter);
|
||||
app.use('/api/investissements-pe', requireAuth, investissementsPeRouter);
|
||||
app.use('/api/remboursements', requireAuth, remboursementsRouter);
|
||||
app.use('/api/simul', requireAuth, simulRouter);
|
||||
app.use('/api/dashboard', requireAuth, dashboardRouter);
|
||||
app.use('/api/dashboard-pe', requireAuth, dashboardPeRouter);
|
||||
app.use('/api/taxreport', requireAuth, taxreportRouter);
|
||||
app.use('/api/plateforme-tax', requireAuth, platefomeTaxRouter);
|
||||
app.use('/api/imports', requireAuth, importsRouter);
|
||||
|
||||
+84
-21
@@ -10,11 +10,17 @@ import ResetPassword from './pages/ResetPassword.jsx';
|
||||
import VerifyEmail from './pages/VerifyEmail.jsx';
|
||||
import Layout from './components/Layout.jsx';
|
||||
import Dashboard from './pages/Dashboard.jsx';
|
||||
import DashboardPe from './pages/DashboardPe.jsx';
|
||||
import DepotsRetraits from './pages/DepotsRetraits.jsx';
|
||||
import DepotsRetraitsSimple from './pages/DepotsRetraitsSimple.jsx';
|
||||
import Investissements from './pages/Investissements.jsx';
|
||||
import InvestissementsPe from './pages/InvestissementsPe.jsx';
|
||||
import InvestissementDetail from './pages/InvestissementDetail.jsx';
|
||||
import InvestissementPeDetail from './pages/InvestissementPeDetail.jsx';
|
||||
import Remboursements from './pages/Remboursements.jsx';
|
||||
import RemboursementsSimple from './pages/RemboursementsSimple.jsx';
|
||||
import Frais from './pages/Frais.jsx';
|
||||
import FraisPe from './pages/FraisPe.jsx';
|
||||
import SimulRemboursements from './pages/SimulRemboursements.jsx';
|
||||
import TaxReport from './pages/TaxReport.jsx';
|
||||
import Settings from './pages/Settings.jsx';
|
||||
@@ -54,6 +60,16 @@ function FraisOnly({ children }) {
|
||||
return children;
|
||||
}
|
||||
|
||||
// Équivalent PE (chantier "frais PE", 17/09/26) — visible seulement après déclaration d'au
|
||||
// moins un frais sur un deal Private Equity (hasFraisPe, cf. AuthContext.jsx / GET /auth/me).
|
||||
// Même mécanisme non réactif que FraisOnly.
|
||||
function FraisPeOnly({ children }) {
|
||||
const { hasFraisPe, loading } = useAuth();
|
||||
if (loading) return <div style={{ padding: 32 }}>Chargement…</div>;
|
||||
if (!hasFraisPe) return <Navigate to="/" replace />;
|
||||
return children;
|
||||
}
|
||||
|
||||
// ── Gating de navigation par workspace (Phase 4, étape 0 — 08/09/26) ────
|
||||
// cf. project_workspaces_transformation.md (mémoire projet). N'agit
|
||||
// aujourd'hui sur rien de visible tant qu'un seul workspace (crowdlending)
|
||||
@@ -67,25 +83,72 @@ function RequireWorkspace({ slug, children }) {
|
||||
return children;
|
||||
}
|
||||
|
||||
// Page d'accueil sensible au workspace actif : le Dashboard crowdlending
|
||||
// tant que ce workspace est actif (comportement actuel, inchangé), sinon un
|
||||
// simple message d'attente — appelé à être remplacé par le vrai contenu du
|
||||
// workspace concerné au fur et à mesure de sa construction (Phase 4+).
|
||||
// Page d'accueil sensible au workspace actif : le Dashboard crowdlending tant
|
||||
// que ce workspace est actif (comportement actuel, inchangé), sinon le
|
||||
// tableau de bord dédié PE (DashboardPe.jsx, chantier "dashboard PE",
|
||||
// 17/09/26) — première version structurelle (KPI + graphiques de
|
||||
// portefeuille), à enrichir ensuite.
|
||||
function WorkspaceHome() {
|
||||
const { active } = useWorkspace() || {};
|
||||
if (active && active.slug !== 'crowdlending') {
|
||||
return (
|
||||
<div style={{ padding: '48px 24px', textAlign: 'center', color: 'var(--text-muted)' }}>
|
||||
<h2 style={{ margin: '0 0 8px', color: 'var(--text)' }}>{active.nom}</h2>
|
||||
<p style={{ margin: 0 }}>
|
||||
{active.description || "Cet espace de travail n'est pas encore disponible."}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (active && active.slug !== 'crowdlending') return <DashboardPe />;
|
||||
return <Dashboard />;
|
||||
}
|
||||
|
||||
// Route /depots-retraits : partagée entre tous les workspaces (17/09/26,
|
||||
// demande explicite d'Olivier de réutiliser le code de la page crowdlending
|
||||
// plutôt que d'en construire une séparée à chaque nouveau workspace). Bascule
|
||||
// sur la version simplifiée (pages/DepotsRetraitsSimple.jsx) pour tout
|
||||
// workspace autre que crowdlending — jamais lié en dur à un slug précis
|
||||
// (ex. "private_equity") pour rester valable si un 3e workspace apparaît un
|
||||
// jour. Fail-open vers la page crowdlending tant que `active` n'est pas
|
||||
// encore chargé, comme RequireWorkspace ci-dessus.
|
||||
function DepotsRetraitsRouter() {
|
||||
const { active } = useWorkspace() || {};
|
||||
if (active && active.slug !== 'crowdlending') return <DepotsRetraitsSimple />;
|
||||
return <DepotsRetraits />;
|
||||
}
|
||||
|
||||
// Même principe que DepotsRetraitsRouter ci-dessus (17/09/26) : la page
|
||||
// Remboursements simplifiée (RemboursementsSimple.jsx, limitée aux types
|
||||
// bonus/intérêts plateforme — pas de type "normal" lié à un investissement,
|
||||
// investissements n'ayant pas encore de workspace_id) sert tout workspace
|
||||
// autre que crowdlending, générique par slug pour rester valable si un 3e
|
||||
// workspace apparaît un jour.
|
||||
function RemboursementsRouter() {
|
||||
const { active } = useWorkspace() || {};
|
||||
if (active && active.slug !== 'crowdlending') return <RemboursementsSimple />;
|
||||
return <Remboursements />;
|
||||
}
|
||||
|
||||
// Même principe (17/09/26) : la page Investissements bascule sur la table
|
||||
// dédiée investissements_pe (InvestissementsPe.jsx) pour tout workspace
|
||||
// autre que crowdlending — modèle de données volontairement différent
|
||||
// (montant unique investi + valorisations périodiques, pas d'échéancier),
|
||||
// cf. project_workspaces_transformation.md.
|
||||
function InvestissementsRouter() {
|
||||
const { active } = useWorkspace() || {};
|
||||
if (active && active.slug !== 'crowdlending') return <InvestissementsPe />;
|
||||
return <Investissements />;
|
||||
}
|
||||
|
||||
// Fiche détaillée /investissements/:id (chantier "fiche détail PE", 17/09/26) :
|
||||
// structure encore minimale côté PE (résumé, valorisations, remboursements,
|
||||
// frais, documents), à enrichir ensuite (cf. InvestissementPeDetail.jsx).
|
||||
function InvestissementDetailRouter() {
|
||||
const { active } = useWorkspace() || {};
|
||||
if (active && active.slug !== 'crowdlending') return <InvestissementPeDetail />;
|
||||
return <InvestissementDetail />;
|
||||
}
|
||||
|
||||
// Même principe (chantier "frais PE", 17/09/26) : la page Frais dédiée existe désormais pour
|
||||
// les deux workspaces (FraisPe.jsx pour tout workspace non-crowdlending), chacune gardée par
|
||||
// son propre indicateur "au moins un frais déclaré" (FraisOnly / FraisPeOnly ci-dessus).
|
||||
function FraisRouter() {
|
||||
const { active } = useWorkspace() || {};
|
||||
if (active && active.slug !== 'crowdlending') return <FraisPeOnly><FraisPe /></FraisPeOnly>;
|
||||
return <FraisOnly><Frais /></FraisOnly>;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [allowRegistration, setAllowRegistration] = useState(true);
|
||||
|
||||
@@ -131,11 +194,11 @@ export default function App() {
|
||||
<Route element={<Protected><Layout /></Protected>}>
|
||||
<Route index element={<WorkspaceHome />} />
|
||||
<Route path="plateformes" element={<RequireWorkspace slug="crowdlending"><Plateformes /></RequireWorkspace>} />
|
||||
<Route path="depots-retraits" element={<RequireWorkspace slug="crowdlending"><DepotsRetraits /></RequireWorkspace>} />
|
||||
<Route path="investissements" element={<RequireWorkspace slug="crowdlending"><Investissements /></RequireWorkspace>} />
|
||||
<Route path="investissements/:id" element={<RequireWorkspace slug="crowdlending"><InvestissementDetail /></RequireWorkspace>} />
|
||||
<Route path="remboursements" element={<RequireWorkspace slug="crowdlending"><Remboursements /></RequireWorkspace>} />
|
||||
<Route path="frais" element={<RequireWorkspace slug="crowdlending"><FraisOnly><Frais /></FraisOnly></RequireWorkspace>} />
|
||||
<Route path="depots-retraits" element={<DepotsRetraitsRouter />} />
|
||||
<Route path="investissements" element={<InvestissementsRouter />} />
|
||||
<Route path="investissements/:id" element={<InvestissementDetailRouter />} />
|
||||
<Route path="remboursements" element={<RemboursementsRouter />} />
|
||||
<Route path="frais" element={<FraisRouter />} />
|
||||
<Route path="simul" element={<RequireWorkspace slug="crowdlending"><SimulRemboursements /></RequireWorkspace>} />
|
||||
<Route path="taxreport" element={<RequireWorkspace slug="crowdlending"><TaxReport /></RequireWorkspace>} />
|
||||
<Route path="assistant-ia" element={<RequireWorkspace slug="crowdlending"><AssistantIA /></RequireWorkspace>} />
|
||||
@@ -145,8 +208,8 @@ export default function App() {
|
||||
<Route path="preferences" element={<Navigate to="/settings?section=apparence" replace />} />
|
||||
<Route path="compte" element={<MonCompte />} />
|
||||
<Route path="admin" element={<AdminOnly><Admin /></AdminOnly>} />
|
||||
<Route path="admin/plateformes" element={<RequireWorkspace slug="crowdlending"><AdminOnly><AdminPlateformes /></AdminOnly></RequireWorkspace>} />
|
||||
<Route path="admin/fiscalite" element={<RequireWorkspace slug="crowdlending"><AdminOnly><AdminFiscalite /></AdminOnly></RequireWorkspace>} />
|
||||
<Route path="admin/plateformes" element={<AdminOnly><AdminPlateformes /></AdminOnly>} />
|
||||
<Route path="admin/fiscalite" element={<AdminOnly><AdminFiscalite /></AdminOnly>} />
|
||||
<Route path="admin/ia" element={<AdminOnly><AdminIA /></AdminOnly>} />
|
||||
<Route path="aide" element={<Aide />} />
|
||||
<Route path="notifications" element={<Notifications />} />
|
||||
|
||||
@@ -6,10 +6,15 @@ function authHeaders() {
|
||||
const token = localStorage.getItem('cl_token');
|
||||
const investisseurId = localStorage.getItem('cl_investisseur_id');
|
||||
const deviceToken = localStorage.getItem('cl_device_token');
|
||||
// Même clé que WorkspaceContext.jsx (KEY_ACTIVE) — miroir de X-Investisseur-Id,
|
||||
// sert à flêcher côté serveur les nouvelles lignes vers le workspace actif
|
||||
// (Étape 2a du retrofit workspace_id, cf. workspaceScope.js).
|
||||
const workspaceId = localStorage.getItem('cl_active_workspace_id');
|
||||
const h = {};
|
||||
if (token) h['Authorization'] = `Bearer ${token}`;
|
||||
if (investisseurId) h['X-Investisseur-Id'] = investisseurId;
|
||||
if (deviceToken) h['X-Device-Token'] = deviceToken;
|
||||
if (workspaceId) h['X-Workspace-Id'] = workspaceId;
|
||||
return h;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Modal from './Modal.jsx';
|
||||
import { useAsyncAction } from '../hooks/useAsyncAction.js';
|
||||
|
||||
/**
|
||||
* Modale de confirmation générique.
|
||||
@@ -7,8 +8,15 @@ import Modal from './Modal.jsx';
|
||||
* title — titre de la modale (défaut : "Confirmer la suppression")
|
||||
* message — texte explicatif
|
||||
* confirmLabel — libellé du bouton de confirmation (défaut : "Supprimer")
|
||||
* onConfirm — callback appelé au clic "Confirmer"
|
||||
* onConfirm — callback appelé au clic "Confirmer" (peut être async)
|
||||
* onCancel — callback appelé au clic "Annuler" ou ✕
|
||||
*
|
||||
* Protégée nativement contre le double-clic (cf. useAsyncAction) : "Confirmer" se
|
||||
* désactive dès le premier clic (retour visuel via le style global button:disabled)
|
||||
* et bloque aussi "Annuler"/✕ tant que la requête est en cours — évite de déclencher
|
||||
* deux fois la même action (suppression, etc.) sur une connexion lente. Le libellé du
|
||||
* bouton n'est pas réécrit ici : certains appelants font déjà varier `confirmLabel`
|
||||
* eux-mêmes pendant l'opération (ex. "Suppression…") et gardent la main dessus.
|
||||
*/
|
||||
export default function ConfirmModal({
|
||||
open,
|
||||
@@ -18,16 +26,19 @@ export default function ConfirmModal({
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}) {
|
||||
const { run, pending } = useAsyncAction();
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title={title}
|
||||
onClose={onCancel}
|
||||
width={440}
|
||||
busy={pending}
|
||||
footer={
|
||||
<>
|
||||
<button className="ghost" type="button" onClick={onCancel}>Annuler</button>
|
||||
<button className="danger" type="button" onClick={onConfirm}>{confirmLabel}</button>
|
||||
<button className="ghost" type="button" disabled={pending} onClick={onCancel}>Annuler</button>
|
||||
<button className="danger" type="button" disabled={pending} onClick={() => run(onConfirm)}>{confirmLabel}</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -44,6 +44,7 @@ export default function FraisFormModal({
|
||||
locked = false,
|
||||
investissementOptions = [],
|
||||
confirmingDelete, setConfirmingDelete,
|
||||
submitting = false,
|
||||
}) {
|
||||
const modalTitle = editingId ? 'Modifier le frais' : 'Ajouter un frais';
|
||||
|
||||
@@ -53,12 +54,13 @@ export default function FraisFormModal({
|
||||
title={modalTitle}
|
||||
onClose={onClose}
|
||||
width={locked ? 480 : 560}
|
||||
busy={submitting}
|
||||
footer={
|
||||
<div style={{ display: 'flex', alignItems: 'center', width: '100%', gap: 8 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
{editingId && onDelete && (
|
||||
!confirmingDelete ? (
|
||||
<button className="danger" onClick={() => setConfirmingDelete(true)}>
|
||||
<button className="danger" disabled={submitting} onClick={() => setConfirmingDelete(true)}>
|
||||
Supprimer
|
||||
</button>
|
||||
) : (
|
||||
@@ -66,14 +68,16 @@ export default function FraisFormModal({
|
||||
<span style={{ fontSize: 13, color: 'var(--danger, #ef4444)', whiteSpace: 'nowrap' }}>
|
||||
Supprimer définitivement ?
|
||||
</span>
|
||||
<button onClick={() => setConfirmingDelete(false)}>Non</button>
|
||||
<button className="danger" onClick={onDelete}>Oui, supprimer</button>
|
||||
<button disabled={submitting} onClick={() => setConfirmingDelete(false)}>Non</button>
|
||||
<button className="danger" disabled={submitting} onClick={onDelete}>{submitting ? 'Suppression…' : 'Oui, supprimer'}</button>
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<button className="ghost" onClick={onClose}>Annuler</button>
|
||||
<button className="primary" onClick={onSubmit}>{editingId ? 'Enregistrer' : 'Ajouter'}</button>
|
||||
<button className="ghost" disabled={submitting} onClick={onClose}>Annuler</button>
|
||||
<button className="primary" disabled={submitting} onClick={onSubmit}>
|
||||
{submitting ? (editingId ? 'Enregistrement…' : 'Ajout…') : (editingId ? 'Enregistrer' : 'Ajouter')}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -73,6 +73,7 @@ export default function InvestissementFormModal({
|
||||
dateCibleRevisionWarning = null,
|
||||
restructurationBlock = null,
|
||||
width = 720,
|
||||
submitting = false,
|
||||
}) {
|
||||
// Vestige côté données : mémorise la dernière souscription valide saisie.
|
||||
// N'est lu nulle part dans le code actuel — conservé tel quel (écriture sans effet)
|
||||
@@ -285,12 +286,13 @@ export default function InvestissementFormModal({
|
||||
title={editingId ? 'Modifier l\'investissement' : 'Nouvel investissement'}
|
||||
onClose={onClose}
|
||||
width={width}
|
||||
busy={submitting}
|
||||
footer={
|
||||
<div style={{ display: 'flex', alignItems: 'center', width: '100%', gap: 8 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
{editingId && onDelete && (
|
||||
!confirmingDelete ? (
|
||||
<button className="danger" onClick={() => setConfirmingDelete(true)}>
|
||||
<button className="danger" disabled={submitting} onClick={() => setConfirmingDelete(true)}>
|
||||
Supprimer
|
||||
</button>
|
||||
) : (
|
||||
@@ -298,16 +300,20 @@ export default function InvestissementFormModal({
|
||||
<span style={{ fontSize: 13, color: 'var(--danger, #ef4444)', whiteSpace: 'nowrap' }}>
|
||||
Supprimer définitivement (documents inclus) ?
|
||||
</span>
|
||||
<button onClick={() => setConfirmingDelete(false)}>Non</button>
|
||||
<button className="danger" onClick={onDelete}>Oui, supprimer</button>
|
||||
<button disabled={submitting} onClick={() => setConfirmingDelete(false)}>Non</button>
|
||||
<button className="danger" disabled={submitting} onClick={onDelete}>{submitting ? 'Suppression…' : 'Oui, supprimer'}</button>
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
{step === 1 && <button onClick={onClose}>Annuler</button>}
|
||||
{step > 1 && <button onClick={goPrev}>Précédent</button>}
|
||||
{step < totalSteps && <button className="primary" onClick={goNext}>Suivant</button>}
|
||||
{step === totalSteps && <button className="primary" onClick={onSubmit}>{editingId ? 'Enregistrer' : 'Créer'}</button>}
|
||||
{step === 1 && <button disabled={submitting} onClick={onClose}>Annuler</button>}
|
||||
{step > 1 && <button disabled={submitting} onClick={goPrev}>Précédent</button>}
|
||||
{step < totalSteps && <button className="primary" disabled={submitting} onClick={goNext}>Suivant</button>}
|
||||
{step === totalSteps && (
|
||||
<button className="primary" disabled={submitting} onClick={onSubmit}>
|
||||
{submitting ? (editingId ? 'Enregistrement…' : 'Création…') : (editingId ? 'Enregistrer' : 'Créer')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import Modal from './Modal.jsx';
|
||||
|
||||
/**
|
||||
* Modale d'ajout/modification d'un deal Private Equity, PAR ÉTAPES (chantier "frais PE",
|
||||
* 17/09/26 — demande Olivier : "adapte la modale d'investissement, un peu sur le style de la
|
||||
* modal crowdlending (par étape) pour pouvoir aussi rentrer les frais").
|
||||
*
|
||||
* Adaptation de InvestissementFormModal.jsx (crowdlending) : Étape 1 "Infos du deal" toujours
|
||||
* présente, Étape 2 "Frais" conditionnelle — uniquement si la plateforme sélectionnée facture
|
||||
* des frais (référentiel frais_plateforme ≠ 'aucun', configuré dans Settings > Plateformes,
|
||||
* déjà générique/non spécifique à un workspace).
|
||||
*
|
||||
* Contrairement à crowdlending (une seule ligne "frais de souscription"), l'étape Frais capture
|
||||
* ICI DEUX lignes indépendantes — Frais d'entrée et Frais de gestion — conformément aux
|
||||
* captures Fundora montrées par Olivier (3 deals, chacun distinguant les deux montants). Chaque
|
||||
* ligne journalise sa propre entrée frais_operations : Frais d'entrée → catégorie
|
||||
* 'souscription' (unique par deal, cf. assertUniqueSouscription côté backend) ; Frais de
|
||||
* gestion → catégorie 'gestion', origine 'souscription' (ce qui la distingue, côté
|
||||
* pré-remplissage, d'un frais de gestion ajouté ultérieurement depuis la page Frais PE ou
|
||||
* depuis la liste des investissements — ceux-là restent en origine 'manuel').
|
||||
*
|
||||
* Composant "bête" : tout le state du formulaire appartient au parent (InvestissementsPe.jsx,
|
||||
* mêmes conventions que Investissements.jsx / InvestissementFormModal.jsx côté crowdlending) —
|
||||
* cette modale ne possède que l'état d'étape courante (`step`).
|
||||
*/
|
||||
export default function InvestissementPeFormModal({
|
||||
open, editingId, onClose,
|
||||
form, setForm, plats, investisseurs, multiDetenteur, comptesInvestisseur,
|
||||
err, submitting, onSubmit, onDelete, investisseurForPlat,
|
||||
}) {
|
||||
const [step, setStep] = useState(1);
|
||||
const formRef = useRef(null);
|
||||
|
||||
useEffect(() => { if (open) setStep(1); }, [open]);
|
||||
|
||||
const platFrais = plats.find(p => String(p.id) === String(form.plateforme_id))?.frais_plateforme;
|
||||
const showFraisStep = !!platFrais && platFrais !== 'aucun';
|
||||
const totalSteps = showFraisStep ? 2 : 1;
|
||||
const stepTitles = showFraisStep ? ['Infos du deal', 'Frais'] : ['Infos du deal'];
|
||||
|
||||
const goNext = () => {
|
||||
if (formRef.current && !formRef.current.reportValidity()) return;
|
||||
setStep(s => Math.min(totalSteps, s + 1));
|
||||
};
|
||||
const goPrev = () => setStep(s => Math.max(1, s - 1));
|
||||
|
||||
const setField = (key, value) => setForm(f => ({ ...f, [key]: value }));
|
||||
|
||||
const handleFraisReglementChange = (prefix, mode) => {
|
||||
setForm(f => ({
|
||||
...f,
|
||||
[`${prefix}_mode_reglement`]: mode,
|
||||
[`${prefix}_compte_id`]: mode === 'compte_courant' ? f[`${prefix}_compte_id`] : '',
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title={editingId ? "Modifier l'investissement" : 'Nouvel investissement'}
|
||||
onClose={onClose}
|
||||
busy={submitting}
|
||||
footer={
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', width: '100%' }}>
|
||||
<div>
|
||||
{editingId && step === 1 && (
|
||||
<button className="danger" disabled={submitting} onClick={onDelete}>Supprimer</button>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
{step === 1 && <button disabled={submitting} onClick={onClose}>Annuler</button>}
|
||||
{step > 1 && <button disabled={submitting} onClick={goPrev}>Précédent</button>}
|
||||
{step < totalSteps && <button className="primary" disabled={submitting} onClick={goNext}>Suivant</button>}
|
||||
{step === totalSteps && (
|
||||
<button className="primary" disabled={submitting} onClick={onSubmit}>
|
||||
{submitting ? (editingId ? 'Enregistrement…' : 'Création…') : (editingId ? 'Enregistrer' : 'Créer')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{totalSteps > 1 && (
|
||||
<>
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 10 }}>
|
||||
{Array.from({ length: totalSteps }).map((_, i) => (
|
||||
<div key={i} style={{
|
||||
flex: 1, height: 4, borderRadius: 2,
|
||||
background: i < step ? 'var(--primary)' : 'var(--border)',
|
||||
}} />
|
||||
))}
|
||||
</div>
|
||||
<div className="text-muted" style={{ fontSize: 13, marginBottom: 14 }}>
|
||||
Étape {step}/{totalSteps} — {stepTitles[step - 1]}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<form ref={formRef}>
|
||||
{err && <div className="error">{err}</div>}
|
||||
|
||||
{step === 1 && (
|
||||
<div className="form-grid">
|
||||
<div style={{ gridColumn: '1 / -1' }}>
|
||||
<label>Nom du deal *</label>
|
||||
<input required value={form.nom_deal} onChange={e => setField('nom_deal', e.target.value)} placeholder="Ex. Cornerstone Growth" />
|
||||
</div>
|
||||
<div>
|
||||
<label>Plateforme *</label>
|
||||
<select required value={form.plateforme_id} onChange={e => {
|
||||
const platId = e.target.value;
|
||||
setForm(f => ({ ...f, plateforme_id: platId, investisseur_id: investisseurForPlat(platId) }));
|
||||
}}>
|
||||
<option value="">—</option>
|
||||
{plats.map(p => <option key={p.id} value={p.id}>{p.nom}{multiDetenteur && p.investisseur_nom ? ` — ${p.investisseur_nom}` : ''}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>Stratégie</label>
|
||||
<input value={form.strategie} onChange={e => setField('strategie', e.target.value)} placeholder="Ex. Capital risque" />
|
||||
</div>
|
||||
<div>
|
||||
<label>Date de souscription *</label>
|
||||
<input type="date" required value={form.date_souscription} onChange={e => setField('date_souscription', e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label>Durée (années)</label>
|
||||
<input type="number" step="0.5" min="0" value={form.duree_annees} onChange={e => setField('duree_annees', e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label>Montant investi (€) *</label>
|
||||
<input type="number" step="0.01" min="0" required value={form.montant_investi} onChange={e => setField('montant_investi', e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label>Objectif</label>
|
||||
<input value={form.objectif} onChange={e => setField('objectif', e.target.value)} placeholder="Ex. x4,5 ou 9% - 12%/an" />
|
||||
</div>
|
||||
<div>
|
||||
<label>Statut</label>
|
||||
<select value={form.statut} onChange={e => setField('statut', e.target.value)}>
|
||||
<option value="en_attente">En attente</option>
|
||||
<option value="valide">Validé</option>
|
||||
<option value="cloture">Clôturé</option>
|
||||
<option value="perte_definitive">Perte définitive</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>Référence</label>
|
||||
<input value={form.reference} onChange={e => setField('reference', e.target.value)} />
|
||||
</div>
|
||||
<div style={{ gridColumn: '1 / -1' }}>
|
||||
<label>Détenteur *</label>
|
||||
<select required value={form.investisseur_id} onChange={e => setField('investisseur_id', e.target.value)}>
|
||||
<option value="">—</option>
|
||||
{investisseurs.filter(i => i.type !== 'entreprise').length > 0 && (
|
||||
<optgroup label="Famille">
|
||||
{investisseurs.filter(i => i.type !== 'entreprise').map(i => (
|
||||
<option key={i.id} value={i.id}>{i.nom}{i.is_principal ? ' (principal)' : ''}</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
{investisseurs.filter(i => i.type === 'entreprise').length > 0 && (
|
||||
<optgroup label="Entreprises">
|
||||
{investisseurs.filter(i => i.type === 'entreprise').map(i => (
|
||||
<option key={i.id} value={i.id}>{i.nom}</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
<div style={{ gridColumn: '1 / -1' }}>
|
||||
<label>Notes</label>
|
||||
<input value={form.notes} onChange={e => setField('notes', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 2 && showFraisStep && (
|
||||
<>
|
||||
<p className="text-muted" style={{ fontSize: 13, marginTop: 0, marginBottom: 14 }}>
|
||||
Cette plateforme facture des frais (entrée, gestion…). Indiquez les montants TTC
|
||||
constatés pour cette souscription, ou laissez un montant à 0/vide si le frais
|
||||
correspondant ne s'applique pas cette fois-ci.
|
||||
</p>
|
||||
<FraisBlock prefix="frais_entree" label="Frais d'entrée" form={form} setField={setField}
|
||||
handleFraisReglementChange={handleFraisReglementChange} comptesInvestisseur={comptesInvestisseur} />
|
||||
<FraisBlock prefix="frais_gestion" label="Frais de gestion" form={form} setField={setField}
|
||||
handleFraisReglementChange={handleFraisReglementChange} comptesInvestisseur={comptesInvestisseur} />
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function FraisBlock({ prefix, label, form, setField, handleFraisReglementChange, comptesInvestisseur }) {
|
||||
const modeSaisie = form[`${prefix}_mode_saisie`];
|
||||
const valeur = form[`${prefix}_valeur`];
|
||||
const modeReglement = form[`${prefix}_mode_reglement`];
|
||||
const compteId = form[`${prefix}_compte_id`];
|
||||
|
||||
return (
|
||||
<div style={{ marginBottom: 18, padding: 12, border: '1px solid var(--border)', borderRadius: 8 }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 10 }}>{label}</div>
|
||||
<div className="form-grid">
|
||||
<div>
|
||||
<label>Mode de saisie</label>
|
||||
<select value={modeSaisie} onChange={e => setField(`${prefix}_mode_saisie`, e.target.value)}>
|
||||
<option value="montant">Montant (€)</option>
|
||||
<option value="pourcentage">% du montant investi</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>{modeSaisie === 'pourcentage' ? `${label} (% du montant investi)` : `${label} TTC (€)`}</label>
|
||||
<input type="number" step="0.01" min="0" value={valeur} onChange={e => setField(`${prefix}_valeur`, e.target.value)} />
|
||||
</div>
|
||||
<div style={{ gridColumn: '1 / -1' }}>
|
||||
<label>Prélèvement</label>
|
||||
<select value={modeReglement} onChange={e => handleFraisReglementChange(prefix, e.target.value)}>
|
||||
<option value="source">À la source (sans mouvement complémentaire)</option>
|
||||
<option value="portefeuille">Versement complémentaire — Porte-monnaie de la plateforme</option>
|
||||
<option value="compte_courant">Versement complémentaire — Compte courant du détenteur</option>
|
||||
</select>
|
||||
</div>
|
||||
{modeReglement === 'compte_courant' && (
|
||||
<div style={{ gridColumn: '1 / -1' }}>
|
||||
<label>Compte de règlement *</label>
|
||||
{comptesInvestisseur.length > 0 ? (
|
||||
<select required value={compteId} onChange={e => setField(`${prefix}_compte_id`, e.target.value)}>
|
||||
<option value="">— Choisir un compte —</option>
|
||||
{comptesInvestisseur.map(c => (
|
||||
<option key={c.id} value={c.id}>{c.nom}{c.banque ? ` — ${c.banque}` : ''}</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<p className="text-muted" style={{ fontSize: 'var(--fs-sm)', margin: '4px 0' }}>
|
||||
Aucun compte défini pour ce détenteur. <a href="/settings?section=comptes" target="_blank" rel="noreferrer">Créer un compte →</a>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -252,12 +252,23 @@ const IconBurger = () => (
|
||||
);
|
||||
|
||||
export default function Layout() {
|
||||
const { sidebarCollapsed, setSidebarCollapsed, toggleSidebar, displayMode, setDisplayMode } = useUi();
|
||||
const { hasFrais } = useAuth();
|
||||
const { active: activeWorkspace } = useWorkspace() || {};
|
||||
const { sidebarCollapsed, setSidebarCollapsed, toggleSidebar, displayMode, setDisplayMode, setWorkspaceColorOverride } = useUi();
|
||||
const { hasFrais, hasFraisPe } = useAuth();
|
||||
const { active: activeWorkspace, hasMultipleWorkspaces } = useWorkspace() || {};
|
||||
|
||||
// Couleur d'accent par espace de travail (17/09/26, cf. AppearanceSection.jsx) :
|
||||
// à chaque changement d'espace actif, applique sa couleur personnalisée
|
||||
// (ou repli sur la couleur de l'application si non définie) — seulement
|
||||
// pertinent quand plusieurs espaces sont actifs (sinon rien à distinguer).
|
||||
useEffect(() => {
|
||||
setWorkspaceColorOverride(hasMultipleWorkspaces ? (activeWorkspace?.color_theme || null) : null);
|
||||
}, [activeWorkspace, hasMultipleWorkspaces, setWorkspaceColorOverride]);
|
||||
// Gating de nav par workspace (Phase 4, étape 0) : fail-open tant que
|
||||
// activeWorkspace n'est pas chargé, cf. App.jsx (RequireWorkspace).
|
||||
const isCrowdlendingActive = !activeWorkspace || activeWorkspace.slug === 'crowdlending';
|
||||
// Lien "Frais" (chantier "frais PE", 17/09/26) : visible dans les deux workspaces, chacun
|
||||
// avec son propre indicateur "au moins un frais déclaré" (hasFrais / hasFraisPe).
|
||||
const showFrais = isCrowdlendingActive ? hasFrais : hasFraisPe;
|
||||
const navigate = useNavigate();
|
||||
const [navIcons, setNavIcons] = useState({});
|
||||
const [aiEnabled, setAiEnabled] = useState(false);
|
||||
@@ -332,11 +343,23 @@ export default function Layout() {
|
||||
<NavLink to="/" end title="Tableau de bord">
|
||||
<NavIcon libFilename={navIcons.dashboard} Fallback={IconDashboard} /><span className="nav-label">Tableau de bord</span>
|
||||
</NavLink>
|
||||
{/* Plateformes reste propre au crowdlending pour l'instant (pas
|
||||
d'équivalent PE — les plateformes PE se gèrent depuis Mes
|
||||
plateformes, settings). Investissements, Dépôts/Retraits et
|
||||
Remboursements (17/09/26) sont en revanche visibles quel que soit
|
||||
le workspace actif — la route bascule automatiquement sur la
|
||||
version adaptée (InvestissementsRouter / DepotsRetraitsRouter /
|
||||
RemboursementsRouter, App.jsx). Chaque lien est conditionné
|
||||
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">
|
||||
<NavIcon libFilename={navIcons.plateforme} Fallback={IconInvestments} /><span className="nav-label">Plateformes</span>
|
||||
</NavLink>
|
||||
)}
|
||||
<NavLink to="/investissements" title="Investissements">
|
||||
<NavIcon libFilename={navIcons.investissement} Fallback={IconInvestments} /><span className="nav-label">Investissements</span>
|
||||
</NavLink>
|
||||
@@ -346,11 +369,13 @@ export default function Layout() {
|
||||
<NavLink to="/remboursements" title="Remboursements">
|
||||
<NavIcon libFilename={navIcons.remboursement} Fallback={IconRepayments} /><span className="nav-label">Remboursements</span>
|
||||
</NavLink>
|
||||
{hasFrais && (
|
||||
{showFrais && (
|
||||
<NavLink to="/frais" title="Frais">
|
||||
<NavIcon libFilename={navIcons.frais} Fallback={IconFrais} /><span className="nav-label">Frais</span>
|
||||
</NavLink>
|
||||
)}
|
||||
{isCrowdlendingActive && (
|
||||
<>
|
||||
<NavLink to="/taxreport" title="Fiscalité">
|
||||
<NavIcon libFilename={navIcons.tax} Fallback={IconFlatTax} /><span className="nav-label">Fiscalité</span>
|
||||
</NavLink>
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
export default function Modal({ open, title, onClose, children, footer, width = 600 }) {
|
||||
export default function Modal({ open, title, onClose, children, footer, width = 600, busy = false }) {
|
||||
if (!open) return null;
|
||||
// busy : une soumission est en cours (cf. useAsyncAction) — on désactive la fermeture
|
||||
// par le ✕ pour éviter de quitter la modale au milieu d'une requête (l'utilisateur perdrait
|
||||
// le suivi de son état et pourrait être tenté de rouvrir/ressaisir).
|
||||
const handleClose = busy ? undefined : onClose;
|
||||
return (
|
||||
<div className="modal-backdrop">
|
||||
<div
|
||||
@@ -9,7 +13,7 @@ export default function Modal({ open, title, onClose, children, footer, width =
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
|
||||
<h3 style={{ margin: 0 }}>{title}</h3>
|
||||
<button className="ghost" onClick={onClose}>✕</button>
|
||||
<button className="ghost" onClick={handleClose} disabled={busy} aria-disabled={busy}>✕</button>
|
||||
</div>
|
||||
{children}
|
||||
{footer && <div style={{ marginTop: 16, display: 'flex', gap: 8, justifyContent: 'flex-end' }}>{footer}</div>}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import * as XLSX from 'xlsx';
|
||||
import { fmtEUR, fmtDate, today } from '../utils/format.js';
|
||||
import { useAsyncAction } from '../hooks/useAsyncAction.js';
|
||||
|
||||
/* ── Éléments partagés entre l'onglet "Mouvements" de DepotsRetraits.jsx et l'onglet
|
||||
"Mouvements Porte-monnaie" de Plateformes.jsx. Toute évolution ici s'applique aux deux
|
||||
@@ -695,9 +696,10 @@ export function CorrectionModal({ open, onClose, onConfirm,
|
||||
const diff = Math.round((Number(declared) - computedBalance) * 100) / 100;
|
||||
const hasDiff = Math.abs(diff) >= 0.005;
|
||||
|
||||
const handleConfirm = () => {
|
||||
onConfirm(hasDiff ? diff : 0);
|
||||
};
|
||||
// Anti-double-soumission (cf. useAsyncAction) : évite de créer deux fois la même correction
|
||||
// sur un double-clic / une connexion lente, avant que la modale ne se ferme.
|
||||
const { run, pending } = useAsyncAction();
|
||||
const handleConfirm = () => run(() => onConfirm(hasDiff ? diff : 0));
|
||||
|
||||
if (!open) return null;
|
||||
return (
|
||||
@@ -773,9 +775,9 @@ export function CorrectionModal({ open, onClose, onConfirm,
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 8 }}>
|
||||
<button onClick={onClose}>Passer</button>
|
||||
<button className="primary" onClick={handleConfirm}>
|
||||
{hasDiff ? 'Créer la correction' : 'OK'}
|
||||
<button disabled={pending} onClick={onClose}>Passer</button>
|
||||
<button className="primary" disabled={pending} onClick={handleConfirm}>
|
||||
{pending ? '…' : (hasDiff ? 'Créer la correction' : 'OK')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -71,6 +71,7 @@ export default function RembFormModal({
|
||||
investissementOptions = [],
|
||||
multiDetenteur = false,
|
||||
confirmingDelete, setConfirmingDelete,
|
||||
submitting = false,
|
||||
}) {
|
||||
const isBonus = !locked && BONUS_VALUES.includes(form.investissement_id);
|
||||
const isInteretsPlateforme = !locked && form.investissement_id === INTERETS_PLATEFORME_VALUE;
|
||||
@@ -128,12 +129,13 @@ export default function RembFormModal({
|
||||
title={modalTitle}
|
||||
onClose={onClose}
|
||||
width={900}
|
||||
busy={submitting}
|
||||
footer={
|
||||
<div style={{ display: 'flex', alignItems: 'center', width: '100%', gap: 8 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
{editingId && onDelete && (
|
||||
!confirmingDelete ? (
|
||||
<button className="danger" onClick={() => setConfirmingDelete(true)}>
|
||||
<button className="danger" disabled={submitting} onClick={() => setConfirmingDelete(true)}>
|
||||
Supprimer
|
||||
</button>
|
||||
) : (
|
||||
@@ -141,14 +143,16 @@ export default function RembFormModal({
|
||||
<span style={{ fontSize: 13, color: 'var(--danger, #ef4444)', whiteSpace: 'nowrap' }}>
|
||||
Supprimer définitivement ?
|
||||
</span>
|
||||
<button onClick={() => setConfirmingDelete(false)}>Non</button>
|
||||
<button className="danger" onClick={onDelete}>Oui, supprimer</button>
|
||||
<button disabled={submitting} onClick={() => setConfirmingDelete(false)}>Non</button>
|
||||
<button className="danger" disabled={submitting} onClick={onDelete}>{submitting ? 'Suppression…' : 'Oui, supprimer'}</button>
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<button onClick={onClose}>Annuler</button>
|
||||
<button className="primary" onClick={onSubmit}>{editingId ? 'Enregistrer' : 'Créer'}</button>
|
||||
<button disabled={submitting} onClick={onClose}>Annuler</button>
|
||||
<button className="primary" disabled={submitting} onClick={onSubmit}>
|
||||
{submitting ? (editingId ? 'Enregistrement…' : 'Création…') : (editingId ? 'Enregistrer' : 'Créer')}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -136,6 +136,7 @@ export default function UserMenu() {
|
||||
const popupRef = useRef(null);
|
||||
const subRef = useRef(null);
|
||||
const closeTimer = useRef(null);
|
||||
const activeRowRef = useRef(null); // bouton (ligne) qui a ouvert le sous-panneau actif
|
||||
|
||||
const famille = investisseurs.filter(i => i.type !== 'entreprise');
|
||||
const entreprises = investisseurs.filter(i => i.type === 'entreprise');
|
||||
@@ -143,7 +144,7 @@ export default function UserMenu() {
|
||||
const POPUP_W = 250;
|
||||
const SUB_W = 230;
|
||||
|
||||
/* ── Calcul position (fixed = échappe overflow:hidden) ────── */
|
||||
/* ── Calcul position du popup principal (fixed = échappe overflow:hidden) ── */
|
||||
const computePosition = useCallback(() => {
|
||||
if (!triggerRef.current) return;
|
||||
const r = triggerRef.current.getBoundingClientRect();
|
||||
@@ -165,16 +166,30 @@ export default function UserMenu() {
|
||||
position: 'fixed', left: mainLeft, bottom: mainBottom,
|
||||
width: mainWidth, top: 'auto', right: 'auto',
|
||||
});
|
||||
setSubStyle({
|
||||
position: 'fixed',
|
||||
left: mainLeft + (sidebarCollapsed ? POPUP_W : mainWidth) + 6,
|
||||
bottom: mainBottom,
|
||||
width: SUB_W, top: 'auto', right: 'auto',
|
||||
});
|
||||
}, [sidebarCollapsed]);
|
||||
|
||||
// Position du sous-panneau (Espace de travail / Vue du profil) : ancrée sur
|
||||
// le bas de la LIGNE cliquée (rowEl), pas sur le popup entier — sinon le
|
||||
// sous-panneau apparaît décalé en bas de la fenêtre dès que la ligne
|
||||
// cliquée n'est pas tout en bas du popup principal (ex. "Espace de
|
||||
// travail", proche du haut). Affiché à droite du popup, à la même hauteur
|
||||
// que la ligne cliquée.
|
||||
const computeSubPosition = useCallback((rowEl) => {
|
||||
if (!rowEl || !popupRef.current) return;
|
||||
const rowRect = rowEl.getBoundingClientRect();
|
||||
const popupRect = popupRef.current.getBoundingClientRect();
|
||||
const vh = window.visualViewport ? window.visualViewport.height : window.innerHeight;
|
||||
|
||||
setSubStyle({
|
||||
position: 'fixed',
|
||||
left: popupRect.right + 6,
|
||||
bottom: vh - rowRect.bottom,
|
||||
width: SUB_W, top: 'auto', right: 'auto',
|
||||
});
|
||||
}, []);
|
||||
|
||||
const openMenu = useCallback(() => { computePosition(); setOpen(true); }, [computePosition]);
|
||||
const closeMenu = useCallback(() => { setOpen(false); setActiveSubPanel(null); }, []);
|
||||
const closeMenu = useCallback(() => { setOpen(false); setActiveSubPanel(null); activeRowRef.current = null; }, []);
|
||||
|
||||
const clearClose = () => clearTimeout(closeTimer.current);
|
||||
const scheduleClose = () => { closeTimer.current = setTimeout(closeMenu, 200); };
|
||||
@@ -192,20 +207,29 @@ export default function UserMenu() {
|
||||
return () => document.removeEventListener('mousedown', onDown);
|
||||
}, [open, closeMenu]);
|
||||
|
||||
/* Recalcul si sidebar change ou si visualViewport change (Safari mobile) */
|
||||
useEffect(() => { if (open) computePosition(); }, [sidebarCollapsed, open, computePosition]);
|
||||
/* Recalcul si sidebar change ou si visualViewport change (Safari mobile) —
|
||||
* y compris le sous-panneau, ré-ancré sur sa ligne d'origine (activeRowRef). */
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
computePosition();
|
||||
if (activeRowRef.current) computeSubPosition(activeRowRef.current);
|
||||
}, [sidebarCollapsed, open, computePosition, computeSubPosition]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const vp = window.visualViewport;
|
||||
if (!vp) return;
|
||||
vp.addEventListener('resize', computePosition);
|
||||
vp.addEventListener('scroll', computePosition);
|
||||
return () => {
|
||||
vp.removeEventListener('resize', computePosition);
|
||||
vp.removeEventListener('scroll', computePosition);
|
||||
const recompute = () => {
|
||||
computePosition();
|
||||
if (activeRowRef.current) computeSubPosition(activeRowRef.current);
|
||||
};
|
||||
}, [open, computePosition]);
|
||||
vp.addEventListener('resize', recompute);
|
||||
vp.addEventListener('scroll', recompute);
|
||||
return () => {
|
||||
vp.removeEventListener('resize', recompute);
|
||||
vp.removeEventListener('scroll', recompute);
|
||||
};
|
||||
}, [open, computePosition, computeSubPosition]);
|
||||
|
||||
/* Handlers ouverture */
|
||||
const onWrapEnter = () => { if (sidebarCollapsed) { clearClose(); openMenu(); } };
|
||||
@@ -281,7 +305,12 @@ export default function UserMenu() {
|
||||
<button
|
||||
className={`user-menu-item user-menu-vue-row${activeSubPanel === 'workspace' ? ' active' : ''}`}
|
||||
role="menuitem"
|
||||
onClick={() => setActiveSubPanel(p => p === 'workspace' ? null : 'workspace')}
|
||||
onClick={(e) => {
|
||||
const next = activeSubPanel === 'workspace' ? null : 'workspace';
|
||||
setActiveSubPanel(next);
|
||||
activeRowRef.current = next ? e.currentTarget : null;
|
||||
if (next) computeSubPosition(e.currentTarget);
|
||||
}}
|
||||
>
|
||||
<WorkspaceBadge size={24} />
|
||||
<span className="user-menu-vue-name">{activeWorkspace?.libelle_menu || activeWorkspace?.nom || 'Espace de travail'}</span>
|
||||
@@ -297,7 +326,12 @@ export default function UserMenu() {
|
||||
<button
|
||||
className={`user-menu-item user-menu-vue-row${activeSubPanel === 'profile' ? ' active' : ''}`}
|
||||
role="menuitem"
|
||||
onClick={() => setActiveSubPanel(p => p === 'profile' ? null : 'profile')}
|
||||
onClick={(e) => {
|
||||
const next = activeSubPanel === 'profile' ? null : 'profile';
|
||||
setActiveSubPanel(next);
|
||||
activeRowRef.current = next ? e.currentTarget : null;
|
||||
if (next) computeSubPosition(e.currentTarget);
|
||||
}}
|
||||
>
|
||||
{activeView === 'all'
|
||||
? <TeamBadge size={24} />
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { fmtEUR } from '../utils/format.js';
|
||||
|
||||
const MOIS_LONG = ['Janvier','Février','Mars','Avril','Mai','Juin','Juillet','Août','Septembre','Octobre','Novembre','Décembre'];
|
||||
|
||||
function endOfMonth(Y, M) {
|
||||
const d = new Date(Y, M, 0);
|
||||
return `${Y}-${String(M).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vision mensuelle — Private Equity (17/09/26). Équivalent PE de
|
||||
* CapitalMensuelTable.jsx (crowdlending), mais avec une sémantique
|
||||
* différente (décision Olivier) : chaque cellule est la VALORISATION
|
||||
* connue en fin de mois (dernière valorisation saisie à cette date, ou
|
||||
* montant_investi si aucune valorisation n'a encore été saisie), et non
|
||||
* un capital en cours qui décroît avec des remboursements — un deal PE
|
||||
* peut donc voir sa valeur monter ou descendre d'un mois à l'autre.
|
||||
*
|
||||
* `rows` : réponse de GET /investissements-pe (id, plateforme_id,
|
||||
* plateforme_nom, montant_investi, date_souscription, statut…).
|
||||
* `valorisations` : réponse de GET /investissements-pe/valorisations
|
||||
* (investissement_pe_id, date_valorisation, valorisation_nette), tous
|
||||
* deals confondus.
|
||||
*/
|
||||
export default function ValorisationMensuelleTable({ rows, valorisations, plats, expandButton }) {
|
||||
const currentYear = new Date().getFullYear();
|
||||
const currentMonth = new Date().getMonth() + 1;
|
||||
|
||||
const [annee, setAnnee] = useState(currentYear);
|
||||
|
||||
const availableYears = useMemo(() => {
|
||||
const set = new Set(rows.map(r => r.date_souscription?.slice(0,4)).filter(Boolean));
|
||||
set.add(String(currentYear));
|
||||
return [...set].map(Number).sort((a,b) => a - b);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [rows]);
|
||||
|
||||
/* ── Historique de valorisations trié, par deal ── */
|
||||
const valorisationsByDeal = useMemo(() => {
|
||||
const map = {};
|
||||
for (const v of (valorisations || [])) {
|
||||
const d = v.date_valorisation?.slice(0, 10);
|
||||
if (!d || v.valorisation_nette == null) continue;
|
||||
if (!map[v.investissement_pe_id]) map[v.investissement_pe_id] = [];
|
||||
map[v.investissement_pe_id].push({ date: d, value: v.valorisation_nette });
|
||||
}
|
||||
for (const k in map) map[k].sort((a, b) => a.date.localeCompare(b.date));
|
||||
return map;
|
||||
}, [valorisations]);
|
||||
|
||||
const getValorisationAtEndOfMonth = (deal, Y, M) => {
|
||||
const endM = endOfMonth(Y, M);
|
||||
if (deal.date_souscription > endM) return 0;
|
||||
let val = deal.montant_investi;
|
||||
for (const v of (valorisationsByDeal[deal.id] || [])) {
|
||||
if (v.date <= endM) val = v.value; else break;
|
||||
}
|
||||
return val;
|
||||
};
|
||||
|
||||
/* ── Agrégat par plateforme ── */
|
||||
const { grid, multiDetenteur } = useMemo(() => {
|
||||
if (!rows.length) return { grid: null, multiDetenteur: false };
|
||||
|
||||
const platMap = {};
|
||||
for (const p of plats) platMap[p.id] = p;
|
||||
|
||||
const byPlat = {};
|
||||
for (const deal of rows) {
|
||||
const pid = deal.plateforme_id;
|
||||
if (!byPlat[pid]) {
|
||||
const p = platMap[pid] || {};
|
||||
byPlat[pid] = {
|
||||
id: pid,
|
||||
nom: deal.plateforme_nom || p.nom || '—',
|
||||
investisseur_id: p.investisseur_id ?? null,
|
||||
detenteur_nom: deal.investisseur_nom || null,
|
||||
months: Array(12).fill(0),
|
||||
};
|
||||
}
|
||||
const row = byPlat[pid];
|
||||
for (let m = 1; m <= 12; m++) row.months[m-1] += getValorisationAtEndOfMonth(deal, annee, m);
|
||||
}
|
||||
|
||||
const allPlats = Object.values(byPlat).filter(p => p.months.some(v => v > 0));
|
||||
const multi = new Set(allPlats.map(p => p.investisseur_id).filter(v => v != null)).size > 1;
|
||||
|
||||
const grid = allPlats.sort((a,b) => b.months.reduce((s,v) => s+v, 0) - a.months.reduce((s,v) => s+v, 0));
|
||||
return { grid, multiDetenteur: multi };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [rows, annee, valorisationsByDeal, plats]);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
if (!grid) return null;
|
||||
const monthTotals = Array.from({ length: 12 }, (_, i) => grid.reduce((s, row) => s + row.months[i], 0));
|
||||
const platMoyennes = grid.map(row => {
|
||||
const nonZero = row.months.filter(v => v > 0);
|
||||
return nonZero.length ? nonZero.reduce((s,v) => s+v, 0) / nonZero.length : 0;
|
||||
});
|
||||
const totalMoyenne = platMoyennes.reduce((s,v) => s+v, 0);
|
||||
const platPoids = platMoyennes.map(m => totalMoyenne > 0 ? (m / totalMoyenne) * 100 : 0);
|
||||
const nonZeroMonthTotals = monthTotals.filter(v => v > 0);
|
||||
const globalMoyenne = nonZeroMonthTotals.length
|
||||
? nonZeroMonthTotals.reduce((s,v) => s+v, 0) / nonZeroMonthTotals.length
|
||||
: 0;
|
||||
return { monthTotals, platMoyennes, platPoids, globalMoyenne };
|
||||
}, [grid]);
|
||||
|
||||
const [windowStart, setWindowStart] = useState(() => {
|
||||
const idx = availableYears.indexOf(currentYear);
|
||||
return Math.max(0, Math.min(Math.max(0, availableYears.length - 3), (idx >= 0 ? idx : availableYears.length - 1) - 1));
|
||||
});
|
||||
const visibleYears = availableYears.length ? availableYears.slice(windowStart, windowStart + 3) : [annee];
|
||||
const canPrev = windowStart > 0;
|
||||
const canNext = windowStart + 3 < availableYears.length;
|
||||
|
||||
return (
|
||||
<div className="solde-chart-wrap" style={{ padding: '24px 24px 16px', marginBottom: 24 }}>
|
||||
|
||||
{/* ── Header ── */}
|
||||
<div className="solde-chart-header">
|
||||
<div className="solde-chart-info">
|
||||
<div style={{ display:'flex', alignItems:'center', gap:5, marginBottom:2 }}>
|
||||
<span style={{ fontSize:13, color:'var(--text-muted)' }}>{`Valorisation · ${annee}`}</span>
|
||||
</div>
|
||||
<div className="solde-chart-value">
|
||||
{stats ? fmtEUR(stats.globalMoyenne) : '—'}
|
||||
<span style={{ fontSize:14, fontWeight:400, color:'var(--text-muted)', marginLeft:8 }}>moy. mensuelle</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="solde-chart-controls">
|
||||
<div className="solde-chart-ranges">
|
||||
<button className="solde-range-btn"
|
||||
onClick={() => setWindowStart(w => Math.max(0, w-1))}
|
||||
disabled={!canPrev} style={{ opacity: canPrev ? 1 : 0.3 }}>‹</button>
|
||||
{visibleYears.map(y => (
|
||||
<button key={y}
|
||||
className={`solde-range-btn${annee === y ? ' active' : ''}`}
|
||||
onClick={() => setAnnee(y)}>
|
||||
{y}
|
||||
</button>
|
||||
))}
|
||||
<button className="solde-range-btn"
|
||||
onClick={() => setWindowStart(w => Math.min(Math.max(0, availableYears.length - 3), w+1))}
|
||||
disabled={!canNext} style={{ opacity: canNext ? 1 : 0.3 }}>›</button>
|
||||
<button className={`solde-range-btn${annee === currentYear ? ' active' : ''}`}
|
||||
onClick={() => setAnnee(currentYear)}>
|
||||
TOUT
|
||||
</button>
|
||||
{expandButton}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Table ── */}
|
||||
{!grid || grid.length === 0 ? (
|
||||
<div style={{ marginTop: 20, color: 'var(--text-muted)', fontSize: 'var(--fs-sm)', padding: '24px 0', textAlign: 'center' }}>
|
||||
Aucune valorisation pour {annee}.
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ overflowX: 'auto', marginTop: 20, position: 'relative', zIndex: 0 }}>
|
||||
<table className="tip-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="tip-th-empty" />
|
||||
<th className="tip-th-year" colSpan={12}>{annee}</th>
|
||||
<th className="tip-th-empty" />
|
||||
<th className="tip-th-empty" />
|
||||
</tr>
|
||||
<tr>
|
||||
<th className="tip-th-name tip-th-name-amber">Plateforme</th>
|
||||
{MOIS_LONG.map((m, i) => (
|
||||
<th key={m}
|
||||
className={`tip-th-month${annee === currentYear && i === currentMonth - 1 ? ' tip-th-month-current' : ''}`}>
|
||||
{m}
|
||||
</th>
|
||||
))}
|
||||
<th className="tip-th-total">Moyenne</th>
|
||||
<th className="tip-th-avg">Poids</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{grid.map((plat, pi) => (
|
||||
<tr key={plat.id} className="tip-row-plat">
|
||||
<td className="tip-td-name">
|
||||
{plat.nom}
|
||||
{multiDetenteur && plat.detenteur_nom && (
|
||||
<span style={{ marginLeft: 6, fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', fontWeight: 400 }}>
|
||||
{plat.detenteur_nom}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
{plat.months.map((v, mi) => (
|
||||
<td key={mi}
|
||||
className={`tip-td-num${annee === currentYear && mi === currentMonth - 1 ? ' tip-col-current' : ''}`}>
|
||||
{v > 0 ? fmtEUR(v) : <span className="tip-dash">—</span>}
|
||||
</td>
|
||||
))}
|
||||
<td className="tip-td-total">
|
||||
{stats.platMoyennes[pi] > 0 ? fmtEUR(stats.platMoyennes[pi]) : <span className="tip-dash">—</span>}
|
||||
</td>
|
||||
<td className="tip-td-avg">
|
||||
{stats.platPoids[pi] > 0 ? (
|
||||
<div style={{ display:'flex', alignItems:'center', gap:5, justifyContent:'flex-end' }}>
|
||||
<div style={{ width:36, height:4, borderRadius:2, background:'var(--surface-2)', overflow:'hidden' }}>
|
||||
<div style={{ width:`${Math.min(100,stats.platPoids[pi])}%`, height:'100%', background:'var(--primary)', borderRadius:2 }} />
|
||||
</div>
|
||||
<span style={{ minWidth:38, textAlign:'right' }}>{stats.platPoids[pi].toFixed(1)} %</span>
|
||||
</div>
|
||||
) : <span className="tip-dash">—</span>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
<tfoot>
|
||||
<tr className="tip-footer-total">
|
||||
<td className="tip-td-name">Toutes les plateformes</td>
|
||||
{stats.monthTotals.map((v, i) => (
|
||||
<td key={i}
|
||||
className={`tip-td-num${annee === currentYear && i === currentMonth - 1 ? ' tip-col-current' : ''}`}>
|
||||
{v > 0 ? fmtEUR(v) : <span className="tip-dash">—</span>}
|
||||
</td>
|
||||
))}
|
||||
<td className="tip-td-total">{fmtEUR(stats.globalMoyenne)}</td>
|
||||
<td className="tip-td-void" />
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
import { useMemo, useState, useRef } from 'react';
|
||||
|
||||
/* ── Constantes ─────────────────────────────────────────────── */
|
||||
const BLUE = 'var(--primary)';
|
||||
const BG = 'var(--surface)';
|
||||
const GRID = 'var(--border)';
|
||||
const LABEL = 'var(--text-muted)';
|
||||
|
||||
const RANGES = ['1J', '7J', '1M', '3M', 'YTD', '1A', 'TOUT'];
|
||||
const MOIS_COURT = ['jan.','fév.','mar.','avr.','mai','juin','juil.','août','sep.','oct.','nov.','déc.'];
|
||||
|
||||
const W = 900, H = 260;
|
||||
const PAD = { top: 16, right: 16, bottom: 32, left: 70 };
|
||||
const plotW = W - PAD.left - PAD.right;
|
||||
const plotH = H - PAD.top - PAD.bottom;
|
||||
|
||||
/* ── Helpers ────────────────────────────────────────────────── */
|
||||
function fmtK(v) {
|
||||
if (v === 0) return '0 €';
|
||||
const abs = Math.abs(v);
|
||||
if (abs >= 1000) return (v / 1000).toLocaleString('fr-FR', { maximumFractionDigits: 0 }) + ' k €';
|
||||
return v.toLocaleString('fr-FR', { maximumFractionDigits: 0 }) + ' €';
|
||||
}
|
||||
|
||||
function fmtAxisDate(dateStr, range) {
|
||||
const d = new Date(dateStr + 'T00:00:00');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
const mon = MOIS_COURT[d.getMonth()];
|
||||
const yr = d.getFullYear();
|
||||
if (range === '1J' || range === '7J') return `${day}/${String(d.getMonth()+1).padStart(2,'0')}`;
|
||||
if (range === '1M' || range === '3M') return `${day} ${mon}`;
|
||||
return `${day}/${String(d.getMonth()+1).padStart(2,'0')}/${String(yr).slice(2)}`;
|
||||
}
|
||||
|
||||
function fmtValueDisplay(v) {
|
||||
return v.toLocaleString('fr-FR', { minimumFractionDigits: 0, maximumFractionDigits: 0 }) + ' €';
|
||||
}
|
||||
|
||||
/**
|
||||
* Graphique "Valorisation totale du portefeuille" — équivalent Private
|
||||
* Equity de InvChart.jsx (crowdlending), mais avec une sémantique
|
||||
* radicalement différente (décision Olivier du 17/09/26) : là où InvChart
|
||||
* cumule des deltas (capital en cours = investi − remboursé), ce composant
|
||||
* agrège pour chaque deal sa DERNIÈRE valeur connue à une date donnée
|
||||
* (montant_investi à la souscription, puis chaque valorisation saisie la
|
||||
* remplace — jamais un delta additif), et somme ces dernières valeurs
|
||||
* connues sur l'ensemble des deals. La courbe peut donc monter ET
|
||||
* descendre au fil des valorisations, contrairement au capital en cours
|
||||
* qui ne fait que décroître.
|
||||
*/
|
||||
export default function ValorisationPeChart({ rows, valorisations, platYear }) {
|
||||
const [range, setRange] = useState('TOUT');
|
||||
const [hover, setHover] = useState(null);
|
||||
const svgRef = useRef(null);
|
||||
const wrapRef = useRef(null);
|
||||
|
||||
const todayStr = new Date().toISOString().slice(0, 10);
|
||||
const currentYear = String(new Date().getFullYear());
|
||||
const cutoffStr = platYear && platYear !== currentYear ? `${platYear}-12-31` : todayStr;
|
||||
const dataCutoffStr = platYear && platYear !== currentYear ? `${platYear}-12-31` : null;
|
||||
|
||||
/* ── 1. Courbe valorisation totale = somme, par deal, de la dernière
|
||||
* valeur connue (montant_investi à la souscription, puis remplacée par
|
||||
* chaque valorisation saisie) ── */
|
||||
const allPoints = useMemo(() => {
|
||||
if (!rows?.length) return [];
|
||||
const rowIds = new Set(rows.map(r => r.id));
|
||||
|
||||
// Événements : chaque événement FIXE la valeur connue du deal (et non
|
||||
// un delta cumulé comme InvChart) — une valorisation remplace la
|
||||
// précédente valeur connue, elle ne s'y ajoute pas.
|
||||
const events = [];
|
||||
for (const r of rows) {
|
||||
const d = r.date_souscription?.slice(0, 10);
|
||||
if (!d || (dataCutoffStr && d > dataCutoffStr)) continue;
|
||||
events.push({ date: d, dealId: r.id, value: r.montant_investi ?? 0 });
|
||||
}
|
||||
if (valorisations?.length) {
|
||||
for (const v of valorisations) {
|
||||
if (!rowIds.has(v.investissement_pe_id)) continue;
|
||||
const d = v.date_valorisation?.slice(0, 10);
|
||||
if (!d || v.valorisation_nette == null || (dataCutoffStr && d > dataCutoffStr)) continue;
|
||||
events.push({ date: d, dealId: v.investissement_pe_id, value: v.valorisation_nette });
|
||||
}
|
||||
}
|
||||
events.sort((a, b) => a.date.localeCompare(b.date) || a.dealId - b.dealId);
|
||||
|
||||
const lastValueByDeal = {};
|
||||
let cum = 0;
|
||||
const cumulated = [];
|
||||
let i = 0;
|
||||
while (i < events.length) {
|
||||
const d = events[i].date;
|
||||
while (i < events.length && events[i].date === d) {
|
||||
const ev = events[i];
|
||||
cum += ev.value - (lastValueByDeal[ev.dealId] ?? 0);
|
||||
lastValueByDeal[ev.dealId] = ev.value;
|
||||
i++;
|
||||
}
|
||||
cumulated.push({ date: d, value: Math.max(0, cum) });
|
||||
}
|
||||
|
||||
// Un point dont la date dépasse aujourd'hui est regroupé sur la date
|
||||
// du jour pour l'affichage (identique à InvChart).
|
||||
const clamped = [];
|
||||
for (const p of cumulated) {
|
||||
const d = p.date > todayStr ? todayStr : p.date;
|
||||
if (clamped.length && clamped[clamped.length - 1].date === d) {
|
||||
clamped[clamped.length - 1] = { date: d, value: p.value };
|
||||
} else {
|
||||
clamped.push({ date: d, value: p.value });
|
||||
}
|
||||
}
|
||||
return clamped;
|
||||
}, [rows, valorisations, dataCutoffStr, todayStr]);
|
||||
|
||||
const currentValue = allPoints.length ? allPoints[allPoints.length - 1].value : 0;
|
||||
|
||||
/* ── 2. Filtrage par plage ── */
|
||||
const points = useMemo(() => {
|
||||
if (!allPoints.length) return [];
|
||||
if (range === 'TOUT') {
|
||||
const pts = [...allPoints];
|
||||
if (pts[pts.length - 1].date < cutoffStr) pts.push({ date: cutoffStr, value: pts[pts.length - 1].value });
|
||||
return pts;
|
||||
}
|
||||
const now = new Date(cutoffStr + 'T00:00:00');
|
||||
let fromDate = new Date(now);
|
||||
switch (range) {
|
||||
case '1J': fromDate.setDate(now.getDate() - 1); break;
|
||||
case '7J': fromDate.setDate(now.getDate() - 7); break;
|
||||
case '1M': fromDate.setMonth(now.getMonth() - 1); break;
|
||||
case '3M': fromDate.setMonth(now.getMonth() - 3); break;
|
||||
case 'YTD': fromDate = new Date(now.getFullYear(), 0, 1); break;
|
||||
case '1A': fromDate.setFullYear(now.getFullYear() - 1); break;
|
||||
}
|
||||
const fromStr = fromDate.toISOString().slice(0, 10);
|
||||
const before = allPoints.filter(p => p.date < fromStr);
|
||||
const startV = before.length ? before[before.length - 1].value : 0;
|
||||
const after = allPoints.filter(p => p.date >= fromStr);
|
||||
const pts = [{ date: fromStr, value: startV }, ...after];
|
||||
if (pts[pts.length - 1].date < cutoffStr) pts.push({ date: cutoffStr, value: pts[pts.length - 1].value });
|
||||
return pts;
|
||||
}, [allPoints, range, cutoffStr]);
|
||||
|
||||
/* ── 3. Échelles ── */
|
||||
const { xScale, yScale, yTicks, xTicks, minDate, maxDate, yZero } = useMemo(() => {
|
||||
if (points.length < 2) return {};
|
||||
const vals = points.map(p => p.value);
|
||||
const dataMin = Math.min(...vals);
|
||||
const dataMax = Math.max(...vals);
|
||||
const lo = Math.min(0, dataMin);
|
||||
const hi = Math.max(0, dataMax);
|
||||
const pad = (hi - lo) * 0.1 || 10;
|
||||
const scaleLo = lo - (lo < 0 ? pad : 0);
|
||||
const scaleHi = hi + pad;
|
||||
const valRange = scaleHi - scaleLo || 1;
|
||||
|
||||
const ts = points.map(p => new Date(p.date + 'T00:00:00').getTime());
|
||||
const minDt = ts[0];
|
||||
const maxDt = ts[ts.length - 1];
|
||||
const dtRange = maxDt - minDt || 1;
|
||||
|
||||
const xScale = d => PAD.left + ((new Date(d + 'T00:00:00').getTime() - minDt) / dtRange) * plotW;
|
||||
const yScale = v => PAD.top + plotH - ((v - scaleLo) / valRange) * plotH;
|
||||
|
||||
const step = (scaleHi - scaleLo) / 4;
|
||||
const yTicks = Array.from({ length: 5 }, (_, i) => ({ v: scaleLo + step * i, y: yScale(scaleLo + step * i) }));
|
||||
|
||||
const nX = Math.min(8, points.length);
|
||||
const xTicks = Array.from({ length: nX }, (_, i) => {
|
||||
const idx = Math.round((i / (nX - 1)) * (points.length - 1));
|
||||
return points[idx];
|
||||
});
|
||||
|
||||
return { xScale, yScale, yTicks, xTicks, minDate: minDt, maxDate: maxDt, yZero: yScale(0) };
|
||||
}, [points]);
|
||||
|
||||
/* ── 4. Chemins SVG ── */
|
||||
const { linePath, areaPath } = useMemo(() => {
|
||||
if (!xScale || points.length < 2) return { linePath: '', areaPath: '' };
|
||||
let line = `M ${xScale(points[0].date).toFixed(1)},${yScale(points[0].value).toFixed(1)}`;
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
line += ` H ${xScale(points[i].date).toFixed(1)} V ${yScale(points[i].value).toFixed(1)}`;
|
||||
}
|
||||
const zeroY = yZero?.toFixed(1) ?? (PAD.top + plotH).toFixed(1);
|
||||
const area = `${line} V ${zeroY} H ${PAD.left} Z`;
|
||||
return { linePath: line, areaPath: area };
|
||||
}, [points, xScale, yScale, yZero]);
|
||||
|
||||
/* ── 5. Hover ── */
|
||||
const handleMouseMove = (e) => {
|
||||
if (!svgRef.current || !xScale || points.length < 2) return;
|
||||
const rect = svgRef.current.getBoundingClientRect();
|
||||
const svgX = ((e.clientX - rect.left) / rect.width) * W;
|
||||
const t = minDate + ((svgX - PAD.left) / plotW) * (maxDate - minDate);
|
||||
let nearest = points[0], minDiff = Infinity;
|
||||
for (const p of points) {
|
||||
const diff = Math.abs(new Date(p.date + 'T00:00:00').getTime() - t);
|
||||
if (diff < minDiff) { minDiff = diff; nearest = p; }
|
||||
}
|
||||
setHover({ x: xScale(nearest.date), y: yScale(nearest.value), value: nearest.value, date: nearest.date });
|
||||
};
|
||||
|
||||
/* ── Tooltip ── */
|
||||
const tooltipStyle = useMemo(() => {
|
||||
if (!hover) return null;
|
||||
const xPct = (hover.x / W) * 100;
|
||||
const yPct = (hover.y / H) * 100;
|
||||
const anchorRight = xPct > 65;
|
||||
return {
|
||||
position: 'absolute',
|
||||
top: `calc(${yPct}% - 64px)`,
|
||||
left: anchorRight ? 'auto' : `calc(${xPct}% + 12px)`,
|
||||
right: anchorRight ? `calc(${100 - xPct}% + 12px)` : 'auto',
|
||||
transform: 'none',
|
||||
pointerEvents: 'none',
|
||||
};
|
||||
}, [hover]);
|
||||
|
||||
if (!allPoints.length) return null;
|
||||
|
||||
const displayLabel = platYear ? `31/12/${platYear}` : "Aujourd'hui";
|
||||
const displayDate = hover
|
||||
? new Date(hover.date + 'T00:00:00').toLocaleDateString('fr-FR', { day: '2-digit', month: 'long', year: 'numeric' })
|
||||
: displayLabel;
|
||||
const displayValue = fmtValueDisplay(hover ? hover.value : currentValue);
|
||||
const tooltipDate = hover
|
||||
? new Date(hover.date + 'T00:00:00').toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="solde-chart-wrap" ref={wrapRef} style={{ position: 'relative' }}>
|
||||
|
||||
{/* ── En-tête ── */}
|
||||
<div className="solde-chart-header">
|
||||
<div className="solde-chart-info">
|
||||
<div className="solde-chart-date">Valorisation totale · {displayDate}</div>
|
||||
<div className="solde-chart-value">{displayValue}</div>
|
||||
</div>
|
||||
<div className="solde-chart-controls">
|
||||
<div className="solde-chart-ranges">
|
||||
{RANGES.map(r => (
|
||||
<button key={r}
|
||||
className={`solde-range-btn${range === r ? ' active' : ''}`}
|
||||
onClick={() => { setRange(r); setHover(null); }}>
|
||||
{r}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── SVG ── */}
|
||||
{xScale && (
|
||||
<svg
|
||||
ref={svgRef}
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
style={{ width: '100%', height: 'auto', display: 'block', cursor: 'crosshair' }}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={() => setHover(null)}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="pe-val-fill" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={BLUE} stopOpacity="0.22" />
|
||||
<stop offset="70%" stopColor={BLUE} stopOpacity="0.06" />
|
||||
<stop offset="100%" stopColor={BLUE} stopOpacity="0" />
|
||||
</linearGradient>
|
||||
<filter id="pe-val-glow">
|
||||
<feGaussianBlur stdDeviation="1.5" result="blur"/>
|
||||
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
{/* Grille horizontale */}
|
||||
{yTicks.map(({ v, y }) => (
|
||||
<g key={v}>
|
||||
<line x1={PAD.left} y1={y} x2={W - PAD.right} y2={y}
|
||||
stroke={GRID} strokeWidth="1" strokeDasharray="3 5" />
|
||||
<text x={PAD.left - 10} y={y + 4} textAnchor="end"
|
||||
fill={LABEL} fontSize="11" fontFamily="system-ui,sans-serif">
|
||||
{fmtK(v)}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
|
||||
{/* Ligne zéro */}
|
||||
{yZero && yZero > PAD.top && yZero < PAD.top + plotH && (
|
||||
<line x1={PAD.left} y1={yZero} x2={W - PAD.right} y2={yZero}
|
||||
stroke={GRID} strokeWidth="1" />
|
||||
)}
|
||||
|
||||
{/* Aire dégradée + courbe */}
|
||||
<path d={areaPath} fill="url(#pe-val-fill)" />
|
||||
<path d={linePath} fill="none" stroke={BLUE} strokeWidth="1.5"
|
||||
filter="url(#pe-val-glow)" strokeLinejoin="round" />
|
||||
|
||||
{/* Labels axe X */}
|
||||
{xTicks.map((p, i) => {
|
||||
const anchor = i === 0 ? 'start' : i === xTicks.length - 1 ? 'end' : 'middle';
|
||||
return (
|
||||
<text key={i} x={xScale(p.date)} y={H - 8}
|
||||
textAnchor={anchor} fill={LABEL} fontSize="10"
|
||||
fontFamily="system-ui,sans-serif">
|
||||
{fmtAxisDate(p.date, range)}
|
||||
</text>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Hover : ligne verticale + point */}
|
||||
{hover && (
|
||||
<g>
|
||||
<line x1={hover.x} y1={PAD.top} x2={hover.x} y2={PAD.top + plotH}
|
||||
stroke={GRID} strokeWidth="1" strokeDasharray="3 4" />
|
||||
<circle cx={hover.x} cy={hover.y} r="4.5"
|
||||
fill={BLUE} stroke={BG} strokeWidth="2" />
|
||||
</g>
|
||||
)}
|
||||
</svg>
|
||||
)}
|
||||
|
||||
{/* ── Tooltip flottant ── */}
|
||||
{hover && tooltipStyle && (
|
||||
<div style={tooltipStyle}>
|
||||
<div className="sg-tooltip">
|
||||
<span className="sg-tooltip-date">{tooltipDate}</span>
|
||||
<span className="sg-tooltip-value">{fmtValueDisplay(hover.value)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -78,9 +78,13 @@ export function AuthProvider({ children }) {
|
||||
// simple assumé : n'apparaît qu'après rechargement de session suivant la toute première
|
||||
// déclaration de frais, pas de mise à jour réactive en direct.
|
||||
const hasFrais = !!user?.has_frais;
|
||||
// Équivalent PE (chantier "frais PE", 17/09/26) — vrai dès qu'au moins un frais a été
|
||||
// déclaré sur un deal Private Equity de l'utilisateur (calculé côté backend, cf.
|
||||
// GET /auth/me, colonne has_frais_pe). Même mécanisme non réactif que hasFrais.
|
||||
const hasFraisPe = !!user?.has_frais_pe;
|
||||
|
||||
return (
|
||||
<AuthCtx.Provider value={{ token, user, loading, login, completeLogin, register, logout, updateUser, deleteAccount, isAdmin, hasFrais }}>
|
||||
<AuthCtx.Provider value={{ token, user, loading, login, completeLogin, register, logout, updateUser, deleteAccount, isAdmin, hasFrais, hasFraisPe }}>
|
||||
{children}
|
||||
</AuthCtx.Provider>
|
||||
);
|
||||
|
||||
@@ -104,6 +104,15 @@ export function UiProvider({ children }) {
|
||||
localStorage.getItem(KEY_ADMIN_COLOR_THEME) || 'classique'
|
||||
);
|
||||
|
||||
// Couleur d'accent propre à l'espace de travail actif (17/09/26, cf.
|
||||
// AppearanceSection.jsx / WorkspaceContext.jsx) : null si l'utilisateur n'a
|
||||
// qu'un seul espace actif, ou si l'espace actif hérite de la couleur de
|
||||
// l'application. Non persistée ici (c'est user_workspaces.color_theme, via
|
||||
// WorkspaceContext, qui fait foi) — juste reflétée dans l'attribut
|
||||
// data-color-theme par Layout.jsx à chaque changement d'espace actif.
|
||||
// Prime sur colorTheme/adminColorTheme quand elle est définie.
|
||||
const [workspaceColorOverride, setWorkspaceColorOverride] = useState(null);
|
||||
|
||||
// ── Thème par défaut (admin) : chargé une fois au montage ───────
|
||||
// Endpoint public (pas d'auth requise) : reste valable sur la page de
|
||||
// connexion, comme dans App.jsx.
|
||||
@@ -116,10 +125,14 @@ export function UiProvider({ children }) {
|
||||
}, []);
|
||||
|
||||
// ── Applique le thème de couleur effectif sur <html> ────────────
|
||||
// Un choix personnel (colorTheme) prime toujours sur le défaut admin.
|
||||
// Ordre de priorité : couleur du workspace actif (si définie) > choix
|
||||
// personnel (colorTheme) > défaut admin.
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-color-theme', colorTheme || adminColorTheme);
|
||||
}, [colorTheme, adminColorTheme]);
|
||||
document.documentElement.setAttribute(
|
||||
'data-color-theme',
|
||||
workspaceColorOverride || colorTheme || adminColorTheme
|
||||
);
|
||||
}, [colorTheme, adminColorTheme, workspaceColorOverride]);
|
||||
|
||||
// ── Sync DB → localStorage au montage ──────────────────────────
|
||||
// La DB fait foi : ses valeurs écrasent le localStorage si présentes.
|
||||
@@ -253,7 +266,7 @@ export function UiProvider({ children }) {
|
||||
}, [persistPref]);
|
||||
|
||||
return (
|
||||
<UiCtx.Provider value={{ sidebarCollapsed, setSidebarCollapsed, toggleSidebar, fontScale, setFontScale, langue, setLangue, devise, setDevise, displayMode, setDisplayMode, chartInterets, setChartInterets, chartCapital, setChartCapital, chartCashback, setChartCashback, pfoAssujetti, setPfoAssujetti, objectifsActifs, setObjectifsActifs, colorTheme, adminColorTheme, setColorTheme }}>
|
||||
<UiCtx.Provider value={{ sidebarCollapsed, setSidebarCollapsed, toggleSidebar, fontScale, setFontScale, langue, setLangue, devise, setDevise, displayMode, setDisplayMode, chartInterets, setChartInterets, chartCapital, setChartCapital, chartCashback, setChartCashback, pfoAssujetti, setPfoAssujetti, objectifsActifs, setObjectifsActifs, colorTheme, adminColorTheme, setColorTheme, workspaceColorOverride, setWorkspaceColorOverride }}>
|
||||
{children}
|
||||
</UiCtx.Provider>
|
||||
);
|
||||
|
||||
@@ -63,6 +63,15 @@ export function WorkspaceProvider({ children }) {
|
||||
api.patch('/preferences', { active_workspace_id: String(id) }).catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Couleur d'accent personnelle par workspace (17/09/26, cf. AppearanceSection.jsx) —
|
||||
// null = hérite de la couleur de l'application (UiContext.jsx). Mise à jour
|
||||
// optimiste locale + persistance en base (user_workspaces.color_theme,
|
||||
// routes/workspaces.js PATCH /:id/color).
|
||||
const setWorkspaceColor = useCallback((workspaceId, value) => {
|
||||
setWorkspaces(ws => ws.map(w => (w.id === workspaceId ? { ...w, color_theme: value } : w)));
|
||||
api.patch(`/workspaces/${workspaceId}/color`, { color_theme: value }).catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Seuls les workspaces activés personnellement (active_by_user) comptent
|
||||
// pour l'affichage — un workspace accordé par l'admin mais désactivé par
|
||||
// l'utilisateur (Settings > Mes espaces de travail) reste invisible.
|
||||
@@ -76,7 +85,7 @@ export function WorkspaceProvider({ children }) {
|
||||
return (
|
||||
<WorkspaceCtx.Provider value={{
|
||||
workspaces, activeWorkspaces, active, activeId,
|
||||
setActiveWorkspace, reload, loading,
|
||||
setActiveWorkspace, setWorkspaceColor, reload, loading,
|
||||
hasMultipleWorkspaces: activeWorkspaces.length > 1,
|
||||
}}>
|
||||
{children}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
|
||||
/**
|
||||
* useAsyncAction — garde anti-double-exécution pour un handler asynchrone
|
||||
* (soumission de formulaire, suppression, action d'un bouton…).
|
||||
*
|
||||
* Contexte : sur une connexion lente vers le serveur (VPN, 4G…), rien
|
||||
* n'indiquait visuellement qu'une requête était en cours. L'utilisateur,
|
||||
* ne voyant pas de retour, cliquait une seconde fois sur "Créer"/"Enregistrer"
|
||||
* avant la fermeture de la modale (qui n'intervient qu'après le succès de la
|
||||
* requête) — ce qui déclenchait un second POST et doublait la saisie en base.
|
||||
*
|
||||
* `run` protège contre ce phénomène à deux niveaux :
|
||||
* 1. Une ref (donc synchrone, pas soumise au re-render React) bloque tout
|
||||
* appel concurrent tant qu'un premier est en cours — même un double-clic
|
||||
* très rapproché, avant que le bouton n'ait eu le temps de se désactiver
|
||||
* visuellement, est ignoré.
|
||||
* 2. `pending` (état React) permet en plus de désactiver l'UI (bouton,
|
||||
* fermeture de la modale) et d'afficher un indicateur de chargement,
|
||||
* pour que l'utilisateur sache que sa requête est bien partie.
|
||||
*
|
||||
* Usage :
|
||||
* const { run, pending } = useAsyncAction();
|
||||
* ...
|
||||
* <button disabled={pending} onClick={() => run(submit)}>
|
||||
* {pending ? 'Enregistrement…' : 'Enregistrer'}
|
||||
* </button>
|
||||
*
|
||||
* `run` transmet ses arguments au handler (utile pour un onSubmit de <form>
|
||||
* qui reçoit l'événement) et retourne sa valeur de résolution. Un appel
|
||||
* ignoré (déjà en cours) résout silencieusement sur `undefined`.
|
||||
*/
|
||||
export function useAsyncAction() {
|
||||
const runningRef = useRef(false);
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
const run = useCallback(async (handler, ...args) => {
|
||||
if (runningRef.current) return undefined;
|
||||
runningRef.current = true;
|
||||
setPending(true);
|
||||
try {
|
||||
return await handler(...args);
|
||||
} finally {
|
||||
runningRef.current = false;
|
||||
setPending(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { run, pending };
|
||||
}
|
||||
@@ -397,7 +397,7 @@ function TauxCreditImpotSection() {
|
||||
)}
|
||||
|
||||
{editing && <TciModal row={editing === 'new' ? null : editing} onClose={() => setEditing(null)} onSaved={load} />}
|
||||
{deleting && <ConfirmModal open title={deleting.title} message={deleting.message} confirmLabel={deleting.confirmLabel} danger={deleting.danger} onConfirm={deleting.onConfirm} onClose={() => setDeleting(null)} />}
|
||||
{deleting && <ConfirmModal open title={deleting.title} message={deleting.message} confirmLabel={deleting.confirmLabel} danger={deleting.danger} onConfirm={deleting.onConfirm} onCancel={() => setDeleting(null)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -46,13 +46,14 @@ const countryLabel = code => COUNTRIES.find(c => c.code === code)?.name ?? code
|
||||
const FISCALITE_LABELS = { flat_tax: 'Flat tax (30%)', sans_fiscalite_locale: 'Sans fiscalité locale', avec_fiscalite_locale: 'Avec fiscalité locale' };
|
||||
const CATEGORIE_PLATEFORME_LABELS = { psfp: 'Prestataire de Services de Financement Participatif', psan: 'Prestataire de Services sur Actifs Numériques' };
|
||||
const FRAIS_PLATEFORME_LABELS = { aucun: 'Aucun frais', entree_gestion_directe: "Frais d'entrée + Frais de gestion (Prélèvement direct)" };
|
||||
const EMPTY_REF = { nom: '', url: '', domiciliation: 'france', fiscalite: 'flat_tax', taux_fiscalite_locale: '', type_produit_fiscal: '2TT', categorie_plateforme: 'psfp', frais_plateforme: 'aucun', methode_remboursement: 'portefeuille', type_pret_defaut: '', freq_interets_defaut: '', description: '', categories: [], categories_inv_ids: [], secteurs_inv_ids: [] };
|
||||
const EMPTY_REF = { nom: '', url: '', domiciliation: 'france', fiscalite: 'flat_tax', taux_fiscalite_locale: '', type_produit_fiscal: '2TT', categorie_plateforme: 'psfp', frais_plateforme: 'aucun', methode_remboursement: 'portefeuille', type_pret_defaut: '', freq_interets_defaut: '', description: '', categories: [], categories_inv_ids: [], secteurs_inv_ids: [], workspace_ids: [] };
|
||||
|
||||
/* ── Modal création / édition référentiel ─────────────────────── */
|
||||
function RefModal({ row, onClose, onSaved }) {
|
||||
const isNew = !row?.id;
|
||||
const [catsInv, setCatsInv] = useState([]);
|
||||
const [secteursInv, setSecteursInv] = useState([]);
|
||||
const [workspaces, setWorkspaces] = useState([]);
|
||||
const [err, setErr] = useState(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
@@ -70,15 +71,30 @@ function RefModal({ row, onClose, onSaved }) {
|
||||
categories: [],
|
||||
categories_inv_ids: (row.categories_inv || []).map(c => c.id),
|
||||
secteurs_inv_ids: (row.secteurs_inv || []).map(s => s.id),
|
||||
workspace_ids: (row.workspaces || []).map(w => w.id),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
api.get('/ref-categories'),
|
||||
api.get('/ref-secteurs'),
|
||||
]).then(([cats, sects]) => {
|
||||
api.get('/admin/workspaces'),
|
||||
]).then(([cats, sects, ws]) => {
|
||||
setCatsInv(cats);
|
||||
setSecteursInv(sects);
|
||||
setWorkspaces(ws);
|
||||
// À la création, présélectionne le workspace "crowdlending" (seul
|
||||
// workspace réel aujourd'hui) — l'utilisateur reste libre d'ajuster ;
|
||||
// au moins une affectation reste exigée à l'enregistrement.
|
||||
if (isNew) {
|
||||
setForm(f => (f.workspace_ids && f.workspace_ids.length > 0) ? f : {
|
||||
...f,
|
||||
workspace_ids: (() => {
|
||||
const cl = ws.find(w => w.slug === 'crowdlending');
|
||||
return cl ? [cl.id] : ws.map(w => w.id);
|
||||
})(),
|
||||
});
|
||||
}
|
||||
}).catch(() => {});
|
||||
}, []); // eslint-disable-line
|
||||
|
||||
@@ -88,6 +104,7 @@ function RefModal({ row, onClose, onSaved }) {
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!form.nom.trim()) { setErr('Le nom est requis.'); return; }
|
||||
if (!form.workspace_ids || form.workspace_ids.length === 0) { setErr('Sélectionnez au moins un espace de travail.'); return; }
|
||||
setSaving(true); setErr(null);
|
||||
try {
|
||||
// Champs gérés par RefModal
|
||||
@@ -105,6 +122,7 @@ function RefModal({ row, onClose, onSaved }) {
|
||||
description: form.description.trim() || null,
|
||||
categories_inv_ids: form.categories_inv_ids || [],
|
||||
secteurs_inv_ids: form.secteurs_inv_ids || [],
|
||||
workspace_ids: form.workspace_ids || [],
|
||||
};
|
||||
|
||||
if (isNew) {
|
||||
@@ -165,6 +183,26 @@ function RefModal({ row, onClose, onSaved }) {
|
||||
<option value="psan">{CATEGORIE_PLATEFORME_LABELS.psan}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>Espaces de travail *</label>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||
{workspaces.map(w => {
|
||||
const checked = (form.workspace_ids || []).includes(w.id);
|
||||
return (
|
||||
<label key={w.id} style={{ display: 'flex', alignItems: 'center', gap: 4, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={checked} style={{ width: 'auto' }}
|
||||
onChange={e => set('workspace_ids', e.target.checked
|
||||
? [...(form.workspace_ids || []), w.id]
|
||||
: (form.workspace_ids || []).filter(id => id !== w.id))} />
|
||||
<span className="chip-cat" style={{ fontSize: 11 }}>{w.nom}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="text-muted" style={{ fontSize: 11, marginTop: 4 }}>
|
||||
Cette entrée du référentiel ne sera visible/utilisable que dans le(s) espace(s) de travail coché(s).
|
||||
</div>
|
||||
</div>
|
||||
<div><label>Description</label>
|
||||
<textarea value={form.description} onChange={e => set('description', e.target.value)}
|
||||
style={{ minHeight: 180, resize: 'vertical' }} placeholder="Informations générales sur la plateforme…" /></div>
|
||||
@@ -327,6 +365,7 @@ function ReferentielSection() {
|
||||
const { search: urlSearch } = useLocation();
|
||||
const [search, setSearch] = useState('');
|
||||
const [filterDomicil, setFilterDomicil] = useState('');
|
||||
const [filterWorkspace, setFilterWorkspace] = useState('');
|
||||
const [filterCategorie, setFilterCategorie] = useState(
|
||||
() => new URLSearchParams(urlSearch).get('filterCat') || ''
|
||||
);
|
||||
@@ -337,6 +376,12 @@ function ReferentielSection() {
|
||||
return [...set].sort((a, b) => a.localeCompare(b, 'fr'));
|
||||
}, [rows]);
|
||||
|
||||
const allWorkspaces = useMemo(() => {
|
||||
const map = new Map();
|
||||
rows.forEach(r => (r.workspaces || []).forEach(w => map.set(w.id, w.nom)));
|
||||
return [...map.entries()].sort((a, b) => a[1].localeCompare(b[1], 'fr'));
|
||||
}, [rows]);
|
||||
|
||||
const allDomiciliations = useMemo(() => {
|
||||
const set = new Set();
|
||||
rows.forEach(r => { if (r.domiciliation) set.add(r.domiciliation); });
|
||||
@@ -346,16 +391,17 @@ function ReferentielSection() {
|
||||
const filteredRows = useMemo(() => {
|
||||
let r = rows;
|
||||
if (filterDomicil) r = r.filter(x => x.domiciliation === filterDomicil);
|
||||
if (filterWorkspace) r = r.filter(x => (x.workspaces || []).some(w => String(w.id) === filterWorkspace));
|
||||
if (filterCategorie) r = r.filter(x => (x.categories_inv || []).some(c => c.nom === filterCategorie));
|
||||
if (search.trim()) {
|
||||
const q = search.trim().toLowerCase();
|
||||
r = r.filter(x => x.nom.toLowerCase().includes(q));
|
||||
}
|
||||
return r;
|
||||
}, [rows, search, filterDomicil, filterCategorie]);
|
||||
}, [rows, search, filterDomicil, filterWorkspace, filterCategorie]);
|
||||
|
||||
const { pagedItems, page, setPage, pageSize, setPageSize, totalPages, totalItems, PAGE_SIZES } =
|
||||
usePagination(filteredRows, 'cl_pagesize_referentiel', [search, filterDomicil, filterCategorie]);
|
||||
usePagination(filteredRows, 'cl_pagesize_referentiel', [search, filterDomicil, filterWorkspace, filterCategorie]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
@@ -486,6 +532,16 @@ function ReferentielSection() {
|
||||
style={{ width: '100%', boxSizing: 'border-box', padding: '9px 14px', fontSize: 14, borderRadius: 6, border: '1px solid var(--border)', background: 'var(--surface)', color: 'var(--text)' }}
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<select
|
||||
value={filterWorkspace}
|
||||
onChange={e => setFilterWorkspace(e.target.value)}
|
||||
style={{ flex: 1, padding: '7px 12px', fontSize: 13, borderRadius: 6, border: '1px solid var(--border)', background: 'var(--surface)', color: 'var(--text)' }}
|
||||
>
|
||||
<option value="">Tous les espaces de travail</option>
|
||||
{allWorkspaces.map(([id, nom]) => (
|
||||
<option key={id} value={String(id)}>{nom}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={filterDomicil}
|
||||
onChange={e => setFilterDomicil(e.target.value)}
|
||||
@@ -504,9 +560,9 @@ function ReferentielSection() {
|
||||
<option value="">Toutes les catégories</option>
|
||||
{allCategories.map(c => <option key={c} value={c}>{c}</option>)}
|
||||
</select>
|
||||
{(search || filterDomicil || filterCategorie) && (
|
||||
{(search || filterDomicil || filterWorkspace || filterCategorie) && (
|
||||
<button
|
||||
onClick={() => { setSearch(''); setFilterDomicil(''); setFilterCategorie(''); }}
|
||||
onClick={() => { setSearch(''); setFilterDomicil(''); setFilterWorkspace(''); setFilterCategorie(''); }}
|
||||
style={{ padding: '7px 12px', fontSize: 13, borderRadius: 6, border: '1px solid var(--border)', background: 'var(--surface-2)', color: 'var(--text-muted)', cursor: 'pointer', whiteSpace: 'nowrap' }}
|
||||
>Effacer les filtres</button>
|
||||
)}
|
||||
@@ -537,15 +593,14 @@ function ReferentielSection() {
|
||||
<th style={{ padding: '10px 8px', width: 40 }}></th>
|
||||
<th style={{ padding: '10px 16px', textAlign: 'left' }}>Nom</th>
|
||||
<th style={{ padding: '10px 8px', textAlign: 'left' }}>Domiciliation</th>
|
||||
<th style={{ padding: '10px 8px', textAlign: 'left' }}>Catégories</th>
|
||||
<th style={{ padding: '10px 8px', textAlign: 'left' }}>Secteurs</th>
|
||||
<th style={{ padding: '10px 8px', textAlign: 'left' }}>Workspaces</th>
|
||||
<th style={{ padding: '10px 8px', textAlign: 'center' }}>Héritage</th>
|
||||
<th style={{ padding: '10px 16px', width: 40 }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredRows.length === 0 ? (
|
||||
<tr><td colSpan={7} style={{ padding: 32, textAlign: 'center', color: 'var(--text-muted)' }}>
|
||||
<tr><td colSpan={6} style={{ padding: 32, textAlign: 'center', color: 'var(--text-muted)' }}>
|
||||
{rows.length === 0
|
||||
? 'Aucune entrée dans le référentiel. Créez-en une ou importez une plateforme orpheline.'
|
||||
: 'Aucun résultat pour ces critères.'}
|
||||
@@ -573,16 +628,9 @@ function ReferentielSection() {
|
||||
<td style={{ padding: '10px 8px', color: 'var(--text-muted)', fontSize: 12 }}><span style={{ display:'inline-flex', alignItems:'center', gap:5 }}><FlagIcon code={row.domiciliation} size={15} />{countryLabel(row.domiciliation)}</span></td>
|
||||
<td style={{ padding: '10px 8px' }}>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
|
||||
{(row.categories_inv || []).slice(0, 2).map(c => <span key={c.id} className="chip-cat" style={{ fontSize: 11 }}>{c.nom}</span>)}
|
||||
{(row.categories_inv || []).length > 2 && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>+{row.categories_inv.length - 2}</span>}
|
||||
{(row.categories_inv || []).length === 0 && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>—</span>}
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: '10px 8px' }}>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
|
||||
{(row.secteurs_inv || []).slice(0, 2).map(s => <span key={s.id} className="chip-sect" style={{ fontSize: 11 }}>{s.nom}</span>)}
|
||||
{(row.secteurs_inv || []).length > 2 && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>+{row.secteurs_inv.length - 2}</span>}
|
||||
{(row.secteurs_inv || []).length === 0 && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>—</span>}
|
||||
{(row.workspaces || []).slice(0, 2).map(w => <span key={w.id} className="chip-workspace" style={{ fontSize: 11 }}>{w.nom}</span>)}
|
||||
{(row.workspaces || []).length > 2 && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>+{row.workspaces.length - 2}</span>}
|
||||
{(row.workspaces || []).length === 0 && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>—</span>}
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: '10px 8px', textAlign: 'center' }}>
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import PageIcon from '../components/PageIcon.jsx';
|
||||
import EmptyState from '../components/EmptyState.jsx';
|
||||
import ValorisationPeChart from '../components/ValorisationPeChart.jsx';
|
||||
import { FRAIS_CATEGORIE_LABELS } from '../components/MouvementsShared.jsx';
|
||||
import { api } from '../api.js';
|
||||
import { useInvestisseur } from '../context/InvestisseurContext.jsx';
|
||||
import { useWorkspace } from '../context/WorkspaceContext.jsx';
|
||||
import { fmtEUR, fmtPct } from '../utils/format.js';
|
||||
import { xirr } from '../utils/xirr.js';
|
||||
|
||||
/**
|
||||
* Tableau de bord Private Equity (chantier "dashboard PE", 17/09/26) — première
|
||||
* version structurelle demandée par Olivier ("un premier jet avec tout") : KPI
|
||||
* de portefeuille (porte-monnaie, capital investi, NAV, TVPI/DPI/RVPI, TRI,
|
||||
* frais cumulés, fraîcheur des valorisations) + graphiques (courbe de
|
||||
* valorisation globale, répartitions par plateforme/stratégie, frais par
|
||||
* catégorie) + classement des deals par performance. Réutilise
|
||||
* ValorisationPeChart (déjà utilisé sur la page Investissements PE) pour la
|
||||
* courbe, et GET /api/dashboard-pe (déjà monté côté backend, jusqu'ici
|
||||
* consommé uniquement par DepotsRetraitsSimple.jsx) pour le solde du
|
||||
* porte-monnaie — même snapshot que le Dashboard crowdlending. Les
|
||||
* répartitions passent par un petit composant local BarBreakdown plutôt que
|
||||
* FraisDistributionChart, dont l'en-tête affiche le mot "Frais" en dur —
|
||||
* trompeur pour une répartition de capital investi.
|
||||
*/
|
||||
|
||||
const STATUT_LABELS = {
|
||||
en_attente: 'En attente',
|
||||
valide: 'Validé',
|
||||
cloture: 'Clôturé',
|
||||
perte_definitive: 'Perte définitive',
|
||||
};
|
||||
|
||||
const PALETTE = [
|
||||
'#1e40af', '#9333ea', '#0d9488', '#f59e0b',
|
||||
'#e8547a', '#10b981', '#f97316', '#06b6d4',
|
||||
];
|
||||
|
||||
const fmtMultiple = (n) => (n != null ? `x${n.toFixed(2).replace('.', ',')}` : '—');
|
||||
|
||||
/* ── Carte KPI ──────────────────────────────────────────────── */
|
||||
function KpiCard({ title, value, note, color }) {
|
||||
return (
|
||||
<div className="kpi">
|
||||
<div className="label">{title}</div>
|
||||
<div style={{ fontSize: '1.35rem', fontWeight: 700, marginTop: 4, color: color || 'var(--text)' }}>{value}</div>
|
||||
{note && <div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{note}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Répartition en barres horizontales (générique, léger) ─────
|
||||
* items : [{ label, value }] — trié par valeur décroissante, largeur des
|
||||
* barres proportionnelle au maximum. Volontairement plus simple qu'un
|
||||
* treemap SVG (DistributionChart / FraisDistributionChart) : ce composant
|
||||
* reste neutre (pas de titre métier en dur) pour être réutilisé sur
|
||||
* plusieurs répartitions différentes sans être trompeur. */
|
||||
function BarBreakdown({ title, items }) {
|
||||
const data = (items || []).filter(i => i.value > 0).sort((a, b) => b.value - a.value);
|
||||
const max = data.length ? data[0].value : 0;
|
||||
const total = data.reduce((s, i) => s + i.value, 0);
|
||||
return (
|
||||
<div className="card">
|
||||
<h3 style={{ margin: '0 0 14px' }}>{title}</h3>
|
||||
{data.length === 0 && (
|
||||
<div className="text-muted" style={{ textAlign: 'center', padding: '20px 0' }}>Aucune donnée</div>
|
||||
)}
|
||||
{data.map((d, i) => (
|
||||
<div key={d.label} style={{ marginBottom: 10 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.85em', marginBottom: 3 }}>
|
||||
<span>{d.label}</span>
|
||||
<span className="text-muted">{fmtEUR(d.value)} · {total > 0 ? Math.round(d.value / total * 100) : 0}%</span>
|
||||
</div>
|
||||
<div style={{ background: 'var(--surface-2)', borderRadius: 6, height: 8, overflow: 'hidden' }}>
|
||||
<div style={{
|
||||
width: `${max > 0 ? (d.value / max * 100) : 0}%`, height: '100%',
|
||||
background: PALETTE[i % PALETTE.length], borderRadius: 6,
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Mini-jauge TVPI (classement par deal) — échelle visuelle 0 → x3 (au-delà,
|
||||
* la barre sature à 100% : l'essentiel est de voir d'un coup d'œil qui est
|
||||
* au-dessus/en-dessous de x1, pas d'avoir une échelle linéaire fidèle à un
|
||||
* multiple hors norme). ── */
|
||||
function TvpiBar({ value }) {
|
||||
if (value == null) return <span className="text-muted">—</span>;
|
||||
const pct = Math.min(value, 3) / 3 * 100;
|
||||
const color = value >= 1 ? 'var(--success)' : 'var(--danger)';
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<div style={{ position: 'relative', width: 90, height: 8, background: 'var(--surface-2)', borderRadius: 4, flexShrink: 0 }}>
|
||||
<div style={{ position: 'absolute', inset: 0, width: `${pct}%`, background: color, borderRadius: 4 }} />
|
||||
<div title="x1 (seuil de rentabilité)" style={{ position: 'absolute', left: `${(1 / 3) * 100}%`, top: -2, width: 1, height: 12, background: 'var(--text-muted)' }} />
|
||||
</div>
|
||||
<span style={{ fontWeight: 600, color }}>{fmtMultiple(value)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DashboardPe() {
|
||||
const { activeId: investisseurId } = useInvestisseur();
|
||||
const { active } = useWorkspace() || {};
|
||||
|
||||
const [rows, setRows] = useState([]);
|
||||
const [valorisations, setValorisations] = useState([]);
|
||||
const [fraisOps, setFraisOps] = useState([]);
|
||||
const [rembs, setRembs] = useState([]);
|
||||
const [wallet, setWallet] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [err, setErr] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!investisseurId) return;
|
||||
setLoading(true);
|
||||
setErr(null);
|
||||
Promise.all([
|
||||
api.get('/investissements-pe'),
|
||||
api.get('/investissements-pe/valorisations'),
|
||||
api.get('/frais-operations'),
|
||||
api.get('/remboursements'),
|
||||
api.get('/dashboard-pe'),
|
||||
]).then(([inv, val, frais, remb, dash]) => {
|
||||
setRows(inv);
|
||||
setValorisations(val);
|
||||
setFraisOps(frais);
|
||||
setRembs(remb);
|
||||
setWallet(dash);
|
||||
}).catch(e => setErr(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, [investisseurId, active?.id]);
|
||||
|
||||
/* ── Agrégats KPI ── */
|
||||
const totals = useMemo(() => {
|
||||
let investi = 0, nav = 0, distributions = 0, fraisCumules = 0, actifs = 0, soldes = 0;
|
||||
const staleDeals = [];
|
||||
const staleThreshold = new Date();
|
||||
staleThreshold.setMonth(staleThreshold.getMonth() - 6);
|
||||
const staleThresholdStr = staleThreshold.toISOString().slice(0, 10);
|
||||
|
||||
for (const r of rows) {
|
||||
investi += r.montant_investi;
|
||||
nav += (r.derniere_valorisation ?? r.montant_investi);
|
||||
distributions += r.distributions_cumulees || 0;
|
||||
const enCours = r.statut === 'valide' || r.statut === 'en_attente';
|
||||
if (enCours) {
|
||||
actifs++;
|
||||
if (!r.derniere_valorisation_date || r.derniere_valorisation_date < staleThresholdStr) {
|
||||
staleDeals.push(r.nom_deal);
|
||||
}
|
||||
} else {
|
||||
soldes++;
|
||||
}
|
||||
}
|
||||
for (const f of fraisOps) fraisCumules += f.montant || 0;
|
||||
|
||||
return {
|
||||
investi, nav, distributions, fraisCumules, actifs, soldes,
|
||||
staleCount: staleDeals.length, staleDeals,
|
||||
tvpi: investi > 0 ? (nav + distributions) / investi : null,
|
||||
dpi: investi > 0 ? distributions / investi : null,
|
||||
rvpi: investi > 0 ? nav / investi : null,
|
||||
};
|
||||
}, [rows, fraisOps]);
|
||||
|
||||
/* ── TRI (XIRR) global du portefeuille — même méthode que le XIRR
|
||||
* crowdlending (Dashboard.jsx) : flux réels datés + valeur résiduelle des
|
||||
* deals encore actifs, valorisée "comme vendue aujourd'hui". ── */
|
||||
const xirrData = useMemo(() => {
|
||||
const cutoff = new Date().toISOString().slice(0, 10);
|
||||
const flows = [];
|
||||
for (const r of rows) {
|
||||
if (!r.date_souscription) continue;
|
||||
flows.push({ amount: -r.montant_investi, date: r.date_souscription });
|
||||
}
|
||||
for (const rb of rembs) {
|
||||
if (rb.investissement_pe_id == null) continue;
|
||||
const d = rb.date_remb?.slice(0, 10);
|
||||
if (!d || d > cutoff) continue;
|
||||
flows.push({ amount: rb.net_recu || 0, date: d });
|
||||
}
|
||||
// Frais réglés hors "source" (vraie sortie de trésorerie du détenteur) —
|
||||
// même logique que le XIRR net crowdlending.
|
||||
for (const fo of fraisOps) {
|
||||
if (fo.mode_reglement !== 'portefeuille' && fo.mode_reglement !== 'compte_courant') continue;
|
||||
const d = fo.date_operation?.slice(0, 10);
|
||||
if (!d || d > cutoff) continue;
|
||||
flows.push({ amount: -(fo.montant || 0), date: d });
|
||||
}
|
||||
let estime = false;
|
||||
for (const r of rows) {
|
||||
if (r.statut !== 'valide' && r.statut !== 'en_attente') continue;
|
||||
const val = r.derniere_valorisation ?? r.montant_investi;
|
||||
if (val > 0.01) { estime = true; flows.push({ amount: val, date: cutoff }); }
|
||||
}
|
||||
flows.sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0));
|
||||
return { rate: xirr(flows), estime };
|
||||
}, [rows, rembs, fraisOps]);
|
||||
|
||||
/* ── Répartitions ── */
|
||||
const capitalParPlateforme = useMemo(() => {
|
||||
const map = {};
|
||||
for (const r of rows) map[r.plateforme_nom] = (map[r.plateforme_nom] || 0) + r.montant_investi;
|
||||
return Object.entries(map).map(([label, value]) => ({ label, value }));
|
||||
}, [rows]);
|
||||
|
||||
const capitalParStrategie = useMemo(() => {
|
||||
const map = {};
|
||||
for (const r of rows) {
|
||||
const k = r.strategie || 'Non renseignée';
|
||||
map[k] = (map[k] || 0) + r.montant_investi;
|
||||
}
|
||||
return Object.entries(map).map(([label, value]) => ({ label, value }));
|
||||
}, [rows]);
|
||||
|
||||
const fraisParCategorie = useMemo(() => {
|
||||
const map = {};
|
||||
for (const f of fraisOps) {
|
||||
const k = FRAIS_CATEGORIE_LABELS[f.categorie] || f.categorie || 'Autre';
|
||||
map[k] = (map[k] || 0) + (f.montant || 0);
|
||||
}
|
||||
return Object.entries(map).map(([label, value]) => ({ label, value }));
|
||||
}, [fraisOps]);
|
||||
|
||||
/* ── Classement des deals par TVPI ── */
|
||||
const ranking = useMemo(() => [...rows].sort((a, b) => {
|
||||
if (a.tvpi == null && b.tvpi == null) return 0;
|
||||
if (a.tvpi == null) return 1;
|
||||
if (b.tvpi == null) return -1;
|
||||
return b.tvpi - a.tvpi;
|
||||
}), [rows]);
|
||||
|
||||
const xirrDisplay = xirrData.rate != null ? fmtPct(xirrData.rate * 100) : 'Non calculable';
|
||||
const xirrColor = xirrData.rate != null ? (xirrData.rate >= 0 ? 'var(--success)' : 'var(--danger)') : undefined;
|
||||
const xirrNote = xirrData.rate == null
|
||||
? 'Pas assez de flux datés pour calculer un taux'
|
||||
: (xirrData.estime ? 'Estimé — deals actifs valorisés à date du jour' : 'Taux définitif — tous les deals sont soldés');
|
||||
|
||||
const staleNote = totals.staleCount === 0
|
||||
? 'Toutes les valorisations actives sont à jour'
|
||||
: totals.staleDeals.slice(0, 3).join(', ') + (totals.staleDeals.length > 3 ? '…' : '');
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="topbar"><h2><PageIcon name="dashboard" />Tableau de bord — {active?.nom || 'Private Equity'}</h2></div>
|
||||
|
||||
{loading && <div className="text-muted" style={{ padding: '24px 0' }}>Chargement…</div>}
|
||||
{err && <div className="error">{err}</div>}
|
||||
|
||||
{!loading && !err && rows.length === 0 && (
|
||||
<EmptyState
|
||||
icon="📈"
|
||||
title="Aucun investissement enregistré"
|
||||
message="Ajoutez votre premier investissement Private Equity pour voir apparaître les indicateurs et graphiques du portefeuille."
|
||||
cta="Ajouter un investissement"
|
||||
to="/investissements?new=1"
|
||||
/>
|
||||
)}
|
||||
|
||||
{!loading && !err && rows.length > 0 && (
|
||||
<>
|
||||
<div className="kpi-grid">
|
||||
<KpiCard title="Capital investi total" value={fmtEUR(totals.investi)} />
|
||||
<KpiCard
|
||||
title="Porte-monnaie"
|
||||
value={wallet ? fmtEUR(wallet.solde_portefeuille_total) : '…'}
|
||||
note="Cash disponible sur les plateformes, hors compte courant"
|
||||
/>
|
||||
<KpiCard title="Valorisation nette (NAV)" value={fmtEUR(totals.nav)} />
|
||||
<KpiCard title="Distributions cumulées" value={fmtEUR(totals.distributions)} />
|
||||
<KpiCard title="TVPI portefeuille" value={fmtMultiple(totals.tvpi)} note="Valeur totale / capital investi" />
|
||||
<KpiCard title="DPI (réalisé)" value={fmtMultiple(totals.dpi)} note="Distributions / capital investi" />
|
||||
<KpiCard title="RVPI (non réalisé)" value={fmtMultiple(totals.rvpi)} note="Valorisation résiduelle / capital investi" />
|
||||
<KpiCard title="TRI (XIRR) global" value={xirrDisplay} color={xirrColor} note={xirrNote} />
|
||||
<KpiCard title="Frais cumulés" value={fmtEUR(totals.fraisCumules)} />
|
||||
<KpiCard title="Deals" value={`${totals.actifs} actif${totals.actifs > 1 ? 's' : ''}`} note={`${totals.soldes} soldé${totals.soldes > 1 ? 's' : ''}`} />
|
||||
<KpiCard
|
||||
title="Valorisations à rafraîchir"
|
||||
value={String(totals.staleCount)}
|
||||
color={totals.staleCount > 0 ? 'var(--warning)' : undefined}
|
||||
note={staleNote}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '5fr 3fr', gap: 16, marginTop: 8, marginBottom: 16 }}>
|
||||
<ValorisationPeChart rows={rows} valorisations={valorisations} platYear={null} />
|
||||
<BarBreakdown title="Capital investi par plateforme" items={capitalParPlateforme} />
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16, marginBottom: 16 }}>
|
||||
<BarBreakdown title="Capital investi par stratégie" items={capitalParStrategie} />
|
||||
<BarBreakdown title="Frais cumulés par catégorie" items={fraisParCategorie} />
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h3 style={{ margin: '0 0 14px' }}>Performance par deal</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Deal</th><th>Plateforme</th><th>Statut</th>
|
||||
<th className="num">Capital investi</th>
|
||||
<th className="num">Distributions</th>
|
||||
<th className="num">Valorisation actuelle</th>
|
||||
<th>TVPI</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ranking.map(r => (
|
||||
<tr key={r.id}>
|
||||
<td>{r.nom_deal}</td>
|
||||
<td className="text-muted">{r.plateforme_nom}</td>
|
||||
<td className="text-muted">{STATUT_LABELS[r.statut] || r.statut}</td>
|
||||
<td className="num">{fmtEUR(r.montant_investi)}</td>
|
||||
<td className="num">{fmtEUR(r.distributions_cumulees || 0)}</td>
|
||||
<td className="num">{fmtEUR(r.derniere_valorisation ?? r.montant_investi)}</td>
|
||||
<td><TvpiBar value={r.tvpi} /></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { usePagination } from '../hooks/usePagination.js';
|
||||
import { useAsyncAction } from '../hooks/useAsyncAction.js';
|
||||
import Pagination from '../components/Pagination.jsx';
|
||||
import PageIcon from '../components/PageIcon.jsx';
|
||||
import EmptyState from '../components/EmptyState.jsx';
|
||||
@@ -115,6 +116,7 @@ export default function DepotsRetraits() {
|
||||
const [editingId, setEditingId] = useState(null);
|
||||
const [form, setForm] = useState(emptyMouvementForm);
|
||||
const [err, setErr] = useState(null);
|
||||
const { run: runSubmit, pending: submitting } = useAsyncAction();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedRow, setSelectedRow] = useState(null);
|
||||
const [drPlatYear, setDrPlatYear] = useState('');
|
||||
@@ -1679,20 +1681,23 @@ export default function DepotsRetraits() {
|
||||
open={modalOpen}
|
||||
title={editingId ? 'Modifier le mouvement' : 'Nouveau mouvement'}
|
||||
onClose={close}
|
||||
busy={submitting}
|
||||
footer={
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', width: '100%' }}>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
{editingId && <button className="danger" onClick={onDelete}>Supprimer</button>}
|
||||
{editingId && <button onClick={() => openCorrection(null)}>Correction de solde</button>}
|
||||
{editingId && <button className="danger" disabled={submitting} onClick={onDelete}>Supprimer</button>}
|
||||
{editingId && <button disabled={submitting} onClick={() => openCorrection(null)}>Correction de solde</button>}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button onClick={close}>Annuler</button>
|
||||
<button className="primary" onClick={submit}>{editingId ? 'Enregistrer' : 'Créer'}</button>
|
||||
<button disabled={submitting} onClick={close}>Annuler</button>
|
||||
<button className="primary" disabled={submitting} onClick={() => runSubmit(submit)}>
|
||||
{submitting ? (editingId ? 'Enregistrement…' : 'Création…') : (editingId ? 'Enregistrer' : 'Créer')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<form onSubmit={submit}>
|
||||
<form onSubmit={e => runSubmit(submit, e)}>
|
||||
{err && <div className="error">{err}</div>}
|
||||
<div className="form-grid">
|
||||
<div>
|
||||
|
||||
@@ -0,0 +1,676 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useLocation, useNavigate } 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 SoldeChart from '../components/SoldeChart.jsx';
|
||||
import DistributionChart from '../components/DistributionChart.jsx';
|
||||
import DepotsMensuelTable from '../components/DepotsMensuelTable.jsx';
|
||||
import { useAsyncAction } from '../hooks/useAsyncAction.js';
|
||||
import { fmtEUR, fmtDate, rangeFromDate } from '../utils/format.js';
|
||||
import { emptyMouvementForm } from '../components/MouvementsShared.jsx';
|
||||
|
||||
/**
|
||||
* Dépôts / Retraits — version simplifiée, utilisée par tout workspace autre
|
||||
* que crowdlending (Private Equity pour l'instant — cf. DepotsRetraitsRouter
|
||||
* dans App.jsx, jamais lié en dur à un slug précis).
|
||||
*
|
||||
* Depuis le 17/09/26 (demande d'Olivier : "adapter la page dépôt/retrait pour
|
||||
* qu'elle ressemble à la page dépôt/retrait de Crowdlending mais avec les
|
||||
* données de Private Equity"), cette page reprend le graphique de solde
|
||||
* cumulé, la Distribution par plateforme, les KPIs (Dépôts/Retraits/Diff/
|
||||
* Porte-monnaie) et les onglets Plateformes + Vision mensuelle de
|
||||
* pages/DepotsRetraits.jsx (crowdlending).
|
||||
*
|
||||
* Volontairement absents pour cette itération (décisions Olivier du 17/09/26) :
|
||||
* - L'onglet "Mouvements" façon crowdlending (journal unifié dépôts/retraits +
|
||||
* corrections + remboursements + souscriptions + frais/prélèvements fiscaux,
|
||||
* cf. MouvementsShared.jsx) : trop couplé à la fiscalité crowdlending, différé.
|
||||
* L'onglet "Mouvements" ci-dessous reste la version simple préexistante
|
||||
* (depots_retraits uniquement), avec les mêmes filtres type/plateforme/année
|
||||
* que les autres onglets.
|
||||
* - L'onglet "Objectifs" (table `objectifs`, pas encore rattachée à un
|
||||
* workspace_id) : hors scope pour l'instant.
|
||||
* - La Distribution est groupée par Plateforme comme côté Crowdlending (le
|
||||
* composant DistributionChart groupe en dur sur plateforme_nom, choix
|
||||
* confirmé par Olivier plutôt qu'un regroupement par deal).
|
||||
*
|
||||
* Le porte-monnaie par plateforme et les KPIs viennent du nouveau endpoint
|
||||
* GET /api/dashboard-pe (backend/src/routes/dashboardPe.js) — équivalent PE de
|
||||
* GET /api/dashboard, basé sur investissements_pe.montant_investi au lieu de
|
||||
* investissements, sans corrections/réinvestissements/frais_operations.
|
||||
*/
|
||||
export default function DepotsRetraitsSimple() {
|
||||
const { activeId: investisseurId, investisseurs } = useInvestisseur();
|
||||
const { active: activeWorkspace } = useWorkspace() || {};
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [rows, setRows] = useState([]);
|
||||
const [allPlats, setAllPlats] = useState([]);
|
||||
const [dashData, setDashData] = useState(null);
|
||||
const [dashDataAsOf, setDashDataAsOf] = useState({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [err, setErr] = useState(null);
|
||||
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState(null);
|
||||
const [form, setForm] = useState(emptyMouvementForm);
|
||||
const [deleteTarget, setDeleteTarget] = useState(null);
|
||||
const { run: runSubmit, pending: submitting } = useAsyncAction();
|
||||
|
||||
const [activeTab, setActiveTab] = useState('plateformes');
|
||||
// Défaut 'TOUT' aligné sur DepotsRetraits.jsx (crowdlending) — évite qu'un
|
||||
// jeu de données encore clairsemé (peu de mouvements, potentiellement plus
|
||||
// vieux qu'1 an) fasse disparaître silencieusement la Distribution.
|
||||
const [chartRange, setChartRange] = useState('TOUT');
|
||||
const [filter, setFilter] = useState({ type: '', plateforme_ids: [] });
|
||||
const [platYear, setPlatYear] = useState('');
|
||||
|
||||
// Plateformes éligibles au workspace actif uniquement (GET /plateformes
|
||||
// n'est pas filtré côté serveur — seulement enrichi de row.workspaces).
|
||||
const plats = useMemo(
|
||||
() => allPlats.filter(p => (p.workspaces || []).some(w => w.id === activeWorkspace?.id)),
|
||||
[allPlats, activeWorkspace],
|
||||
);
|
||||
|
||||
const multiDetenteur = new Set(plats.map(p => p.investisseur_id)).size > 1;
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [dr, pl, dash] = await Promise.all([
|
||||
api.get('/depots-retraits'),
|
||||
api.get('/plateformes'),
|
||||
api.get('/dashboard-pe'),
|
||||
]);
|
||||
setRows(dr);
|
||||
setAllPlats(pl);
|
||||
setDashData(dash);
|
||||
} catch (e) {
|
||||
setErr(e.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (investisseurId) load();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [investisseurId, activeWorkspace?.id]);
|
||||
|
||||
const years = useMemo(
|
||||
() => [...new Set(rows.map(r => r.date_operation.slice(0, 4)))].sort().reverse(),
|
||||
[rows],
|
||||
);
|
||||
|
||||
// Snapshots fin d'année (année sélectionnée + année précédente, pour le
|
||||
// TrendBadge) — chargés à la demande, mêmes principes que DepotsRetraits.jsx.
|
||||
const prevYear = String(Number(platYear || new Date().getFullYear()) - 1);
|
||||
useEffect(() => {
|
||||
if (!platYear) return;
|
||||
[platYear, prevYear].forEach(y => {
|
||||
const key = `${y}-12-31`;
|
||||
if (dashDataAsOf[key]) return;
|
||||
api.get('/dashboard-pe', { asOf: key })
|
||||
.then(snap => setDashDataAsOf(prev => ({ ...prev, [key]: snap })))
|
||||
.catch(() => {});
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [platYear, investisseurId, activeWorkspace?.id]);
|
||||
|
||||
const investisseurForPlat = (platId) => {
|
||||
const plat = plats.find(p => String(p.id) === String(platId));
|
||||
return plat?.investisseur_id ? String(plat.investisseur_id) : String(investisseurId || '');
|
||||
};
|
||||
|
||||
const openAdd = () => {
|
||||
setEditingId(null);
|
||||
const firstId = plats[0]?.id || '';
|
||||
setForm({ ...emptyMouvementForm, plateforme_id: firstId, investisseur_id: investisseurForPlat(firstId) });
|
||||
setErr(null);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
// Ouverture auto depuis le bouton global "Nouveau dépôt/retrait" (topbar/burger,
|
||||
// cf. Layout.jsx — même ?new=1 que la page crowdlending). Le paramètre est
|
||||
// retiré de l'URL dès sa lecture, indépendamment du chargement des
|
||||
// plateformes ; un ref différé ouvre la modale une fois `plats` disponible,
|
||||
// pour ne jamais perdre la demande si elle arrive avant le premier chargement.
|
||||
const pendingNew = useRef(false);
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
if (params.get('new') !== '1') return;
|
||||
navigate('/depots-retraits', { replace: true });
|
||||
if (plats.length > 0) {
|
||||
openAdd();
|
||||
} else {
|
||||
pendingNew.current = true;
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [location.search]);
|
||||
|
||||
useEffect(() => {
|
||||
if (pendingNew.current && plats.length > 0) {
|
||||
pendingNew.current = false;
|
||||
openAdd();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [plats.length]);
|
||||
|
||||
const openEdit = (r) => {
|
||||
setEditingId(r.id);
|
||||
setForm({
|
||||
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 || '',
|
||||
});
|
||||
setErr(null);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const close = () => { setModalOpen(false); setEditingId(null); setForm(emptyMouvementForm); setErr(null); };
|
||||
|
||||
const submit = async (e) => {
|
||||
e?.preventDefault?.(); setErr(null);
|
||||
const payload = {
|
||||
investisseur_id: Number(form.investisseur_id) || undefined,
|
||||
plateforme_id: Number(form.plateforme_id),
|
||||
date_operation: form.date_operation,
|
||||
type: form.type,
|
||||
montant: Number(form.montant),
|
||||
libelle: form.libelle || undefined,
|
||||
reference: form.reference || undefined,
|
||||
notes: form.notes || undefined,
|
||||
};
|
||||
try {
|
||||
if (editingId) await api.put(`/depots-retraits/${editingId}`, payload);
|
||||
else await api.post('/depots-retraits', payload);
|
||||
close();
|
||||
await load();
|
||||
} catch (e) { setErr(e.message); }
|
||||
};
|
||||
|
||||
const onDelete = async () => {
|
||||
if (!deleteTarget) return;
|
||||
try {
|
||||
await api.del(`/depots-retraits/${deleteTarget.id}`);
|
||||
setDeleteTarget(null);
|
||||
await load();
|
||||
} catch (e) { setErr(e.message); }
|
||||
};
|
||||
|
||||
/* ── Graphique / Distribution / KPIs (mêmes principes que DepotsRetraits.jsx,
|
||||
* sans le filtre mois — pas d'onglet Mouvements multi-source ici) ── */
|
||||
const kpiRows = rows.filter(r => {
|
||||
if (filter.plateforme_ids.length > 0 && !filter.plateforme_ids.includes(String(r.plateforme_id))) return false;
|
||||
if (platYear && r.date_operation.slice(0, 4) !== platYear) return false;
|
||||
return true;
|
||||
});
|
||||
const chartRows = kpiRows.filter(r => !filter.type || r.type === filter.type);
|
||||
const distributionRows = useMemo(() => {
|
||||
const from = rangeFromDate(chartRange);
|
||||
if (!from) return chartRows;
|
||||
return chartRows.filter(r => r.date_operation.slice(0, 10) >= from);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [chartRows, chartRange]);
|
||||
|
||||
const totals = kpiRows.reduce((acc, r) => {
|
||||
if (r.type === 'depot') acc.depots += r.montant; else acc.retraits += r.montant;
|
||||
return acc;
|
||||
}, { depots: 0, retraits: 0 });
|
||||
|
||||
const soldeFromSnapshot = (snap) => {
|
||||
if (!snap) return null;
|
||||
if (filter.plateforme_ids.length > 0) {
|
||||
return (snap.cashByPlatform ?? [])
|
||||
.filter(c => filter.plateforme_ids.includes(String(c.plateforme_id)))
|
||||
.reduce((s, c) => s + (c.solde_portefeuille ?? 0), 0);
|
||||
}
|
||||
return snap.solde_portefeuille_total ?? null;
|
||||
};
|
||||
|
||||
const kpiSoldePortefeuille = (() => {
|
||||
if (!platYear) {
|
||||
if (filter.plateforme_ids.length === 1 && dashData) {
|
||||
const p = dashData.cashByPlatform?.find(c => String(c.plateforme_id) === filter.plateforme_ids[0]);
|
||||
return p?.solde_portefeuille ?? null;
|
||||
}
|
||||
return dashData?.solde_portefeuille_total ?? null;
|
||||
}
|
||||
return soldeFromSnapshot(dashDataAsOf[`${platYear}-12-31`]);
|
||||
})();
|
||||
const prevKpiSoldePortefeuille = platYear ? soldeFromSnapshot(dashDataAsOf[`${prevYear}-12-31`]) : null;
|
||||
|
||||
const prevTotals = useMemo(() => {
|
||||
if (!platYear) return { depots: 0, retraits: 0 };
|
||||
return rows.filter(r => {
|
||||
if (filter.plateforme_ids.length > 0 && !filter.plateforme_ids.includes(String(r.plateforme_id))) return false;
|
||||
return r.date_operation.slice(0, 4) === prevYear;
|
||||
}).reduce((acc, r) => {
|
||||
if (r.type === 'depot') acc.depots += r.montant; else acc.retraits += r.montant;
|
||||
return acc;
|
||||
}, { depots: 0, retraits: 0 });
|
||||
}, [rows, filter.plateforme_ids, platYear, prevYear]);
|
||||
|
||||
/* ── Données de l'onglet Plateformes (directement depuis dashboard-pe,
|
||||
* pas de recalcul client contrairement à DepotsRetraits.jsx — le endpoint
|
||||
* PE supporte déjà ?asOf nativement) ── */
|
||||
const platData = useMemo(() => {
|
||||
const src = platYear
|
||||
? (dashDataAsOf[`${platYear}-12-31`]?.cashByPlatform ?? [])
|
||||
: (dashData?.cashByPlatform ?? []);
|
||||
return [...src].sort((a, b) =>
|
||||
a.nom.localeCompare(b.nom, 'fr') || (a.detenteur_nom ?? '').localeCompare(b.detenteur_nom ?? '', 'fr'));
|
||||
}, [dashData, dashDataAsOf, platYear]);
|
||||
|
||||
const platTotals = useMemo(() => platData.reduce((acc, p) => {
|
||||
acc.depots += p.depots; acc.retraits += p.retraits;
|
||||
acc.solde_net += p.solde_net; acc.solde_portefeuille += p.solde_portefeuille ?? 0;
|
||||
return acc;
|
||||
}, { depots: 0, retraits: 0, solde_net: 0, solde_portefeuille: 0 }), [platData]);
|
||||
|
||||
/* ── Onglet Mouvements : liste simple depots_retraits, avec les mêmes
|
||||
* filtres type/plateforme/année que les autres onglets ── */
|
||||
const mouvementsRows = useMemo(
|
||||
() => [...kpiRows]
|
||||
.filter(r => !filter.type || r.type === filter.type)
|
||||
.sort((a, b) => b.date_operation.localeCompare(a.date_operation) || b.id - a.id),
|
||||
[kpiRows, filter.type],
|
||||
);
|
||||
|
||||
const hasActiveFilter = filter.type || filter.plateforme_ids.length > 0 || platYear;
|
||||
const clearFilters = () => { setFilter({ type: '', plateforme_ids: [] }); setPlatYear(''); };
|
||||
|
||||
if (!loading && plats.length === 0) return (
|
||||
<>
|
||||
<div className="topbar"><h2><PageIcon name="depots-retraits" />Dépôts / Retraits</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 pour pouvoir y enregistrer des dépôts et retraits.`}
|
||||
cta="Configurer mes plateformes"
|
||||
to="/settings?section=plateformes&openAdd=1"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
if (!loading && !modalOpen && rows.length === 0) return (
|
||||
<>
|
||||
<div className="topbar"><h2><PageIcon name="depots-retraits" />Dépôts / Retraits</h2></div>
|
||||
<EmptyState
|
||||
icon="💶"
|
||||
title="Aucun dépôt enregistré"
|
||||
message="Vos plateformes sont configurées, mais aucun dépôt n'a encore été enregistré."
|
||||
cta="Ajouter un dépôt"
|
||||
to="/depots-retraits?new=1"
|
||||
/>
|
||||
<Modal
|
||||
open={modalOpen}
|
||||
title={editingId ? 'Modifier le mouvement' : 'Nouveau dépôt/retrait'}
|
||||
onClose={close}
|
||||
busy={submitting}
|
||||
footer={
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button disabled={submitting} onClick={close}>Annuler</button>
|
||||
<button className="primary" disabled={submitting} onClick={() => runSubmit(submit)}>
|
||||
{submitting ? 'Création…' : 'Créer'}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<DepotsRetraitsForm form={form} setForm={setForm} plats={plats} investisseurs={investisseurs}
|
||||
multiDetenteur={multiDetenteur} err={err} runSubmit={runSubmit} submit={submit} investisseurForPlat={investisseurForPlat} />
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="topbar">
|
||||
<h2><PageIcon name="depots-retraits" />Dépôts / Retraits</h2>
|
||||
{hasActiveFilter && (
|
||||
<button className="dr-clear-btn" onClick={clearFilters}>✕ Effacer les filtres</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{err && <div className="error" style={{ margin: '0 0 16px' }}>{err}</div>}
|
||||
|
||||
{/* ── Graphiques ── */}
|
||||
<div className="charts-row">
|
||||
<SoldeChart rows={chartRows} range={chartRange} onRangeChange={setChartRange} />
|
||||
<DistributionChart rows={distributionRows} />
|
||||
</div>
|
||||
|
||||
{/* ── KPIs ── */}
|
||||
<div className="dr-kpi-row">
|
||||
<div
|
||||
className={`kpi dr-kpi-clickable${filter.type === 'depot' ? ' dr-kpi-active dr-kpi-active-success' : ''}`}
|
||||
onClick={() => setFilter(f => ({ ...f, type: f.type === 'depot' ? '' : 'depot' }))}
|
||||
title="Filtrer sur les dépôts"
|
||||
>
|
||||
<div className="label">Dépôts</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
|
||||
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fmtEUR(totals.depots)}</span>
|
||||
{platYear && <TrendBadge current={totals.depots} prev={prevTotals.depots} />}
|
||||
</div>
|
||||
{platYear && <div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{fmtEUR(prevTotals.depots)} en {prevYear}</div>}
|
||||
</div>
|
||||
<div
|
||||
className={`kpi dr-kpi-clickable${filter.type === 'retrait' ? ' dr-kpi-active dr-kpi-active-danger' : ''}`}
|
||||
onClick={() => setFilter(f => ({ ...f, type: f.type === 'retrait' ? '' : 'retrait' }))}
|
||||
title="Filtrer sur les retraits"
|
||||
>
|
||||
<div className="label">Retraits</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
|
||||
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fmtEUR(totals.retraits)}</span>
|
||||
{platYear && <TrendBadge current={totals.retraits} prev={prevTotals.retraits} invert={true} />}
|
||||
</div>
|
||||
{platYear && <div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{fmtEUR(prevTotals.retraits)} en {prevYear}</div>}
|
||||
</div>
|
||||
<div
|
||||
className={`kpi${hasActiveFilter ? ' dr-kpi-clickable dr-kpi-active dr-kpi-active-neutral' : ''}`}
|
||||
onClick={hasActiveFilter ? clearFilters : undefined}
|
||||
title={hasActiveFilter ? 'Effacer les filtres' : undefined}
|
||||
style={hasActiveFilter ? { cursor: 'pointer' } : {}}
|
||||
>
|
||||
<div className="label">Diff. Dépôts vs Retraits</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
|
||||
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fmtEUR(totals.depots - totals.retraits)}</span>
|
||||
{platYear && <TrendBadge current={totals.depots - totals.retraits} prev={prevTotals.depots - prevTotals.retraits} />}
|
||||
</div>
|
||||
{platYear && <div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{fmtEUR(prevTotals.depots - prevTotals.retraits)} en {prevYear}</div>}
|
||||
</div>
|
||||
<div className="kpi" title="Cash disponible sur les porte-monnaie des plateformes">
|
||||
<div className="label">Porte-monnaie</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
|
||||
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{kpiSoldePortefeuille !== null ? fmtEUR(kpiSoldePortefeuille) : '…'}</span>
|
||||
{platYear && kpiSoldePortefeuille !== null && <TrendBadge current={kpiSoldePortefeuille} prev={prevKpiSoldePortefeuille} />}
|
||||
</div>
|
||||
{platYear && <div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{fmtEUR(prevKpiSoldePortefeuille)} en {prevYear}</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Onglets ── */}
|
||||
<div className="dr-tabs">
|
||||
<button className={`dr-tab${activeTab === 'plateformes' ? ' active' : ''}`}
|
||||
onClick={() => setActiveTab('plateformes')}>Plateformes</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')}>Mouvements</button>
|
||||
</div>
|
||||
|
||||
{/* ====== ONGLET PLATEFORMES ====== */}
|
||||
{activeTab === 'plateformes' && (
|
||||
<div style={{ padding: '0 24px' }}>
|
||||
<div className="card" style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
|
||||
<h3 style={{ margin: 0 }}>
|
||||
Répartition par plateforme
|
||||
{platYear && <span style={{ marginLeft: 8 }}>pour l'année {platYear}</span>}
|
||||
</h3>
|
||||
<select value={platYear} onChange={e => setPlatYear(e.target.value)}
|
||||
style={{
|
||||
fontSize: 'var(--fs-xs)', padding: '4px 8px', height: 30,
|
||||
borderRadius: 6, border: '1px solid var(--border)',
|
||||
background: 'var(--surface-2)', color: 'var(--text-muted)',
|
||||
cursor: 'pointer', outline: 'none',
|
||||
}}>
|
||||
<option value="">Toutes les années</option>
|
||||
{years.map(y => <option key={y} value={y}>{y}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
{!dashData ? (
|
||||
<div className="text-muted" style={{ padding: '12px 0' }}>Chargement…</div>
|
||||
) : (
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Plateforme</th>{multiDetenteur && <th>Détenteur</th>}<th className="num">Dépôts</th><th className="num">Retraits</th><th className="num">Diff. Dépôts vs Retraits</th><th className="num">Porte-monnaie</th><th className="num">Poids</th><th style={{ width: 28 }}></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{platData.length === 0 && (
|
||||
<tr><td colSpan={multiDetenteur ? 8 : 7} className="text-muted" style={{ textAlign: 'center' }}>Aucune plateforme</td></tr>
|
||||
)}
|
||||
{(() => {
|
||||
const totalSolde = platTotals.solde_net;
|
||||
return platData.map(p => {
|
||||
const isActive = filter.plateforme_ids.includes(String(p.plateforme_id));
|
||||
const poids = totalSolde !== 0 ? (p.solde_net / totalSolde) * 100 : 0;
|
||||
return (
|
||||
<tr key={p.plateforme_id}
|
||||
className={`dr-row${isActive ? ' dr-row-selected' : ''}`}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => setFilter(f => ({
|
||||
...f,
|
||||
plateforme_ids: isActive
|
||||
? f.plateforme_ids.filter(id => id !== String(p.plateforme_id))
|
||||
: [...f.plateforme_ids, String(p.plateforme_id)]
|
||||
}))}>
|
||||
<td><span style={{ fontWeight: isActive ? 600 : undefined }}>{p.nom}</span></td>
|
||||
{multiDetenteur && <td className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>{p.detenteur_nom || '—'}</td>}
|
||||
<td className="num">{fmtEUR(p.depots)}</td>
|
||||
<td className="num">{fmtEUR(p.retraits)}</td>
|
||||
<td className="num">{fmtEUR(p.solde_net)}</td>
|
||||
<td className="num">{fmtEUR(p.solde_portefeuille ?? 0)}</td>
|
||||
<td className="num">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, justifyContent: 'flex-end' }}>
|
||||
<div style={{ width: 48, height: 5, borderRadius: 3, background: 'var(--surface-2)', overflow: 'hidden' }}>
|
||||
<div style={{ width: `${Math.max(0, poids)}%`, height: '100%', background: 'var(--primary)', borderRadius: 3 }} />
|
||||
</div>
|
||||
<span style={{ minWidth: 36, textAlign: 'right', fontSize: 'var(--fs-sm)' }}>{poids.toFixed(1)} %</span>
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ textAlign: 'center', color: 'var(--text-muted)', fontSize: 11 }}>{isActive ? '✕' : ''}</td>
|
||||
</tr>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</tbody>
|
||||
{platData.length > 1 && (
|
||||
<tfoot>
|
||||
<tr style={{ borderTop: '2px solid var(--border)', fontWeight: 700, background: 'var(--surface-2)' }}>
|
||||
<td>Total — {platData.length} plateformes</td>
|
||||
{multiDetenteur && <td />}
|
||||
<td className="num">{fmtEUR(platTotals.depots)}</td>
|
||||
<td className="num">{fmtEUR(platTotals.retraits)}</td>
|
||||
<td className="num">{fmtEUR(platTotals.solde_net)}</td>
|
||||
<td className="num">{fmtEUR(platTotals.solde_portefeuille)}</td>
|
||||
<td className="num"><span style={{ fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>100 %</span></td>
|
||||
<td />
|
||||
</tr>
|
||||
</tfoot>
|
||||
)}
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ====== ONGLET VISION MENSUELLE ====== */}
|
||||
{activeTab === 'vision-mensuelle' && (
|
||||
<div style={{ padding: '0 24px' }}>
|
||||
<DepotsMensuelTable allRows={rows} plats={plats} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ====== ONGLET MOUVEMENTS ====== */}
|
||||
{activeTab === 'mouvements' && (
|
||||
<div style={{ padding: '0 24px' }}>
|
||||
<div className="card">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 14 }}>
|
||||
<h3 style={{ margin: 0 }}>Mouvements</h3>
|
||||
<button className="primary" onClick={openAdd}>+ Nouveau dépôt/retrait</button>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th><th>Plateforme</th>{multiDetenteur && <th>Détenteur</th>}
|
||||
<th>Type</th><th className="num">Montant</th><th>Libellé</th><th style={{ width: 40 }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{mouvementsRows.length === 0 && (
|
||||
<tr><td colSpan={multiDetenteur ? 7 : 6} className="text-muted" style={{ textAlign: 'center' }}>Aucun mouvement</td></tr>
|
||||
)}
|
||||
{mouvementsRows.map(r => (
|
||||
<tr key={r.id} className="dr-row" style={{ cursor: 'pointer' }} onClick={() => openEdit(r)}>
|
||||
<td>{fmtDate(r.date_operation)}</td>
|
||||
<td>{r.plateforme_nom}</td>
|
||||
{multiDetenteur && <td className="text-muted">{r.plateforme_detenteur_nom || '—'}</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={() => setDeleteTarget(r)}>✕</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
open={modalOpen}
|
||||
title={editingId ? 'Modifier le mouvement' : 'Nouveau dépôt/retrait'}
|
||||
onClose={close}
|
||||
busy={submitting}
|
||||
footer={
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', width: '100%' }}>
|
||||
<div>
|
||||
{editingId && (
|
||||
<button className="danger" disabled={submitting}
|
||||
onClick={() => { const target = { id: editingId }; close(); setDeleteTarget(target); }}>
|
||||
Supprimer
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button disabled={submitting} onClick={close}>Annuler</button>
|
||||
<button className="primary" disabled={submitting} onClick={() => runSubmit(submit)}>
|
||||
{submitting ? (editingId ? 'Enregistrement…' : 'Création…') : (editingId ? 'Enregistrer' : 'Créer')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<DepotsRetraitsForm form={form} setForm={setForm} plats={plats} investisseurs={investisseurs}
|
||||
multiDetenteur={multiDetenteur} err={err} runSubmit={runSubmit} submit={submit} investisseurForPlat={investisseurForPlat} />
|
||||
</Modal>
|
||||
|
||||
<ConfirmModal
|
||||
open={!!deleteTarget}
|
||||
title="Supprimer ce mouvement ?"
|
||||
message="Cette action est irréversible."
|
||||
confirmLabel="Supprimer"
|
||||
onConfirm={onDelete}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Indicateur de progression (copié de pages/DepotsRetraits.jsx — pas
|
||||
* exporté par MouvementsShared.jsx) ── */
|
||||
function TrendBadge({ current, prev, invert = false }) {
|
||||
if (prev == null || Math.abs(prev) < 0.005) return null;
|
||||
const diff = current - prev;
|
||||
const pct = (diff / prev) * 100;
|
||||
const up = diff > 0;
|
||||
const neutral = diff === 0;
|
||||
const good = neutral ? null : (invert ? !up : up);
|
||||
const color = neutral ? 'var(--text-muted)' : good ? '#16a34a' : '#dc2626';
|
||||
const bg = neutral ? 'var(--surface-2)' : good ? 'rgba(34,197,94,0.12)' : 'rgba(239,68,68,0.12)';
|
||||
const arrow = neutral ? '→' : up ? '↗' : '↘';
|
||||
const label = `${up ? '+' : ''}${Math.abs(pct) < 10 ? pct.toFixed(1) : Math.round(pct)}%`;
|
||||
return (
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 3,
|
||||
padding: '2px 8px', borderRadius: 20,
|
||||
background: bg, color, fontSize: '0.76em', fontWeight: 600,
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{arrow} {label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Formulaire d'ajout/édition — factorisé pour être identique dans les deux
|
||||
* points d'entrée (page avec mouvements existants, et état vide). Champs
|
||||
* strictement alignés sur le Schema de backend/src/routes/depotsRetraits.js. */
|
||||
function DepotsRetraitsForm({ form, setForm, plats, investisseurs, multiDetenteur, err, runSubmit, submit, investisseurForPlat }) {
|
||||
return (
|
||||
<form onSubmit={e => runSubmit(submit, e)}>
|
||||
{err && <div className="error">{err}</div>}
|
||||
<div className="form-grid">
|
||||
<div>
|
||||
<label>Plateforme *</label>
|
||||
<select required value={form.plateforme_id} onChange={e => {
|
||||
const platId = e.target.value;
|
||||
setForm(f => ({ ...f, plateforme_id: platId, investisseur_id: investisseurForPlat(platId) }));
|
||||
}}>
|
||||
<option value="">—</option>
|
||||
{plats.map(p => <option key={p.id} value={p.id}>{p.nom}{multiDetenteur && p.investisseur_nom ? ` — ${p.investisseur_nom}` : ''}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>Date *</label>
|
||||
<input type="date" required value={form.date_operation} onChange={e => setForm({ ...form, date_operation: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<label>Type *</label>
|
||||
<select required value={form.type} onChange={e => setForm({ ...form, 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={form.montant} onChange={e => setForm({ ...form, montant: e.target.value })} />
|
||||
</div>
|
||||
<div style={{ gridColumn: '1 / -1' }}>
|
||||
<label>Libellé</label>
|
||||
<input value={form.libelle} onChange={e => setForm({ ...form, libelle: e.target.value })} placeholder="Ex. Appel de fonds" />
|
||||
</div>
|
||||
<div>
|
||||
<label>Référence</label>
|
||||
<input value={form.reference} onChange={e => setForm({ ...form, reference: e.target.value })} />
|
||||
</div>
|
||||
<div style={{ gridColumn: '1 / -1' }}>
|
||||
<label>Détenteur *</label>
|
||||
<select required value={form.investisseur_id} onChange={e => setForm({ ...form, investisseur_id: e.target.value })}>
|
||||
<option value="">—</option>
|
||||
{investisseurs.filter(i => i.type !== 'entreprise').length > 0 && (
|
||||
<optgroup label="Famille">
|
||||
{investisseurs.filter(i => i.type !== 'entreprise').map(i => (
|
||||
<option key={i.id} value={i.id}>{i.nom}{i.is_principal ? ' (principal)' : ''}</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
{investisseurs.filter(i => i.type === 'entreprise').length > 0 && (
|
||||
<optgroup label="Entreprises">
|
||||
{investisseurs.filter(i => i.type === 'entreprise').map(i => (
|
||||
<option key={i.id} value={i.id}>{i.nom}</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
<div style={{ gridColumn: '1 / -1' }}>
|
||||
<label>Notes</label>
|
||||
<input value={form.notes} onChange={e => setForm({ ...form, notes: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
|
||||
import { api } from '../api.js';
|
||||
import { useInvestisseur } from '../context/InvestisseurContext.jsx';
|
||||
import { usePagination } from '../hooks/usePagination.js';
|
||||
import { useAsyncAction } from '../hooks/useAsyncAction.js';
|
||||
import Pagination from '../components/Pagination.jsx';
|
||||
import PageIcon from '../components/PageIcon.jsx';
|
||||
import EmptyState from '../components/EmptyState.jsx';
|
||||
@@ -198,6 +199,7 @@ export default function Frais() {
|
||||
};
|
||||
|
||||
const closeModal = () => { setModalOpen(false); setErr(null); setConfirmingDelete(false); };
|
||||
const { run: runSubmit, pending: submitting } = useAsyncAction();
|
||||
|
||||
// Callback générique (onFieldChange) — mêmes effets de bord que le formulaire d'origine
|
||||
// (InvestissementDetail.jsx) : changer mode_reglement réinitialise compte_id sauf en passant
|
||||
@@ -540,8 +542,9 @@ export default function Frais() {
|
||||
form={form}
|
||||
onFieldChange={setField}
|
||||
err={err}
|
||||
onSubmit={submit}
|
||||
onDelete={onDelete}
|
||||
onSubmit={e => runSubmit(submit, e)}
|
||||
onDelete={() => runSubmit(onDelete)}
|
||||
submitting={submitting}
|
||||
comptesInvestisseur={comptesInvestisseur}
|
||||
investissementOptions={investissementOptions}
|
||||
confirmingDelete={confirmingDelete}
|
||||
|
||||
@@ -0,0 +1,565 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { api } from '../api.js';
|
||||
import { useInvestisseur } from '../context/InvestisseurContext.jsx';
|
||||
import { useWorkspace } from '../context/WorkspaceContext.jsx';
|
||||
import { usePagination } from '../hooks/usePagination.js';
|
||||
import { useAsyncAction } from '../hooks/useAsyncAction.js';
|
||||
import Pagination from '../components/Pagination.jsx';
|
||||
import PageIcon from '../components/PageIcon.jsx';
|
||||
import EmptyState from '../components/EmptyState.jsx';
|
||||
import FraisFormModal from '../components/FraisFormModal.jsx';
|
||||
import FraisCumulChart from '../components/FraisCumulChart.jsx';
|
||||
import FraisDistributionChart from '../components/FraisDistributionChart.jsx';
|
||||
import { FRAIS_CATEGORIE_LABELS } from '../components/MouvementsShared.jsx';
|
||||
import { fmtEUR, fmtDate, today } from '../utils/format.js';
|
||||
|
||||
/**
|
||||
* Page dédiée "Frais" pour le workspace Private Equity (chantier "frais PE", 17/09/26 —
|
||||
* demande Olivier : "il faut une page sur les frais"). Adaptation à l'identique de Frais.jsx
|
||||
* (crowdlending) — même structure (graphiques, KPI, onglets Plateformes / Typologie /
|
||||
* Investissements / Opérations), parité complète demandée explicitement par Olivier — avec
|
||||
* les différences suivantes :
|
||||
* - source des données : /investissements-pe (deals) au lieu de /investissements, et
|
||||
* GET /frais-operations sans filtre se borne déjà au workspace ACTIF côté backend
|
||||
* (resolveActiveWorkspaceId), donc renvoie naturellement les frais PE ici ;
|
||||
* - 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
|
||||
* dans App.jsx.
|
||||
*
|
||||
* Le composant FraisFormModal (partagé, inchangé) attend en interne un champ de formulaire
|
||||
* nommé `investissement_id` pour son sélecteur "Investissement" — ce nom est conservé tel
|
||||
* quel dans l'état local de cette page (setField / emptyForm ci-dessous) et traduit en
|
||||
* `investissement_pe_id` uniquement au moment de construire le payload envoyé à l'API
|
||||
* (submit ci-dessous), pour ne rien modifier au composant partagé avec crowdlending.
|
||||
*/
|
||||
|
||||
const FRAIS_MODE_LABELS = {
|
||||
source: 'À la source',
|
||||
portefeuille: 'Porte-monnaie',
|
||||
compte_courant: 'Compte courant',
|
||||
remboursement: 'Retenu sur remboursement',
|
||||
};
|
||||
|
||||
function emptyForm() {
|
||||
return { investissement_id: '', montant: '', mode_reglement: 'source', compte_id: '', date_operation: today(), notes: '', categorie: 'autre' };
|
||||
}
|
||||
|
||||
export default function FraisPe() {
|
||||
const { activeId, activeView } = useInvestisseur();
|
||||
const { active: activeWorkspace } = useWorkspace() || {};
|
||||
|
||||
const [allFraisOps, setAllFraisOps] = useState([]);
|
||||
const [deals, setDeals] = useState([]);
|
||||
const [allPlats, setAllPlats] = useState([]);
|
||||
const [platLoaded, setPlatLoaded] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
/* Onglets (parité avec Frais.jsx) */
|
||||
const [activeTab, setActiveTab] = useState('plateformes');
|
||||
|
||||
/* Modale ajout/édition */
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState(null);
|
||||
const [form, setForm] = useState(emptyForm());
|
||||
const [err, setErr] = useState(null);
|
||||
const [confirmingDelete, setConfirmingDelete] = useState(false);
|
||||
const [comptesInvestisseur, setComptesInvestisseur] = useState([]);
|
||||
|
||||
// Plateformes éligibles au workspace actif uniquement (GET /plateformes n'est pas filtré
|
||||
// côté serveur — seulement enrichi de row.workspaces), même patron que InvestissementsPe.jsx.
|
||||
const plateformes = useMemo(
|
||||
() => allPlats.filter(p => (p.workspaces || []).some(w => w.id === activeWorkspace?.id)),
|
||||
[allPlats, activeWorkspace],
|
||||
);
|
||||
|
||||
const multiDetenteur = new Set(plateformes.map(p => p.investisseur_id)).size > 1;
|
||||
|
||||
const load = async () => {
|
||||
if (!activeId && activeView !== 'all') return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const scopeParams = activeView === 'all' ? { scope: 'all' } : {};
|
||||
const [f, d] = await Promise.all([
|
||||
api.get('/frais-operations'),
|
||||
api.get('/investissements-pe', scopeParams),
|
||||
]);
|
||||
setAllFraisOps(f);
|
||||
setDeals(d);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/plateformes').then(d => { setAllPlats(d); setPlatLoaded(true); }).catch(() => setPlatLoaded(true));
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); /* eslint-disable-next-line */ }, [activeId, activeView, activeWorkspace?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
const deal = deals.find(d => String(d.id) === String(form.investissement_id));
|
||||
if (!modalOpen || !deal?.investisseur_id) { setComptesInvestisseur([]); return; }
|
||||
api.get(`/investissements/comptes-par-investisseur/${deal.investisseur_id}`)
|
||||
.then(setComptesInvestisseur)
|
||||
.catch(() => setComptesInvestisseur([]));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [modalOpen, form.investissement_id]);
|
||||
|
||||
const fraisFiltered = allFraisOps;
|
||||
|
||||
const fraisTotal = useMemo(() => fraisFiltered.reduce((s, f) => s + (f.montant || 0), 0), [fraisFiltered]);
|
||||
const fraisMoyen = fraisFiltered.length ? fraisTotal / fraisFiltered.length : 0;
|
||||
const fraisSouscriptionTotal = useMemo(() =>
|
||||
fraisFiltered.filter(f => f.categorie === 'souscription').reduce((s, f) => s + (f.montant || 0), 0),
|
||||
[fraisFiltered]
|
||||
);
|
||||
const fraisDistributionTotal = useMemo(() =>
|
||||
fraisFiltered.filter(f => f.categorie === 'distribution').reduce((s, f) => s + (f.montant || 0), 0),
|
||||
[fraisFiltered]
|
||||
);
|
||||
|
||||
/* ── Répartition par catégorie (onglet "Typologie") ── */
|
||||
const byCategorie = useMemo(() => {
|
||||
const map = {};
|
||||
for (const f of fraisFiltered) {
|
||||
const key = f.categorie || 'autre';
|
||||
if (!map[key]) map[key] = { key, label: FRAIS_CATEGORIE_LABELS[key] || key, montant: 0, count: 0 };
|
||||
map[key].montant += f.montant || 0;
|
||||
map[key].count += 1;
|
||||
}
|
||||
return Object.values(map).sort((a, b) => b.montant - a.montant);
|
||||
}, [fraisFiltered]);
|
||||
|
||||
/* ── Répartition par plateforme (onglet "Plateformes") ── */
|
||||
const byPlateforme = useMemo(() => {
|
||||
const map = {};
|
||||
for (const f of fraisFiltered) {
|
||||
const key = f.plateforme_id;
|
||||
if (!map[key]) map[key] = { key, label: f.plateforme_nom || '—', detenteur: f.investisseur_nom || '—', montant: 0, count: 0 };
|
||||
map[key].montant += f.montant || 0;
|
||||
map[key].count += 1;
|
||||
}
|
||||
return Object.values(map).sort((a, b) => b.montant - a.montant);
|
||||
}, [fraisFiltered]);
|
||||
|
||||
/* ── Frais par deal (onglet "Investissements") — uniquement les deals concernés par au
|
||||
moins un frais ── */
|
||||
const byInvestissement = useMemo(() => {
|
||||
const map = {};
|
||||
for (const f of fraisFiltered) {
|
||||
const key = f.investissement_pe_id;
|
||||
if (!map[key]) map[key] = {
|
||||
key,
|
||||
plateforme: f.plateforme_nom || '—',
|
||||
detenteur: f.investisseur_nom || '—',
|
||||
projet: f.nom_deal || '—',
|
||||
souscription: 0,
|
||||
distribution: 0,
|
||||
total: 0,
|
||||
};
|
||||
const cat = f.categorie || 'autre';
|
||||
if (cat === 'souscription') map[key].souscription += f.montant || 0;
|
||||
if (cat === 'distribution') map[key].distribution += f.montant || 0;
|
||||
map[key].total += f.montant || 0;
|
||||
}
|
||||
return Object.values(map).sort((a, b) => {
|
||||
const pa = a.plateforme.toLowerCase(), pb = b.plateforme.toLowerCase();
|
||||
if (pa !== pb) return pa.localeCompare(pb, 'fr');
|
||||
return a.projet.localeCompare(b.projet, 'fr');
|
||||
});
|
||||
}, [fraisFiltered]);
|
||||
|
||||
/* ── Tableau détaillé paginé (onglet "Opérations") ── */
|
||||
const {
|
||||
pagedItems: pagedFrais, page, setPage, pageSize, setPageSize,
|
||||
totalPages, totalItems, PAGE_SIZES,
|
||||
} = usePagination(fraisFiltered, 'pe_pagesize_frais', []);
|
||||
|
||||
/* ── Deals pour le sélecteur de la modale (triés plateforme puis deal) — mappés sur les
|
||||
mêmes clés que FraisFormModal attend (plateforme_nom / nom_projet), cf. doc en tête. ── */
|
||||
const investissementOptions = useMemo(() =>
|
||||
deals.slice()
|
||||
.sort((a, b) => {
|
||||
const pa = (a.plateforme_nom || '').toLowerCase();
|
||||
const pb = (b.plateforme_nom || '').toLowerCase();
|
||||
if (pa !== pb) return pa.localeCompare(pb, 'fr');
|
||||
return (a.nom_deal || '').localeCompare(b.nom_deal || '', 'fr');
|
||||
})
|
||||
.map(d => ({ id: d.id, plateforme_nom: d.plateforme_nom, nom_projet: d.nom_deal })),
|
||||
[deals]
|
||||
);
|
||||
|
||||
/* ── CRUD ── */
|
||||
const openAdd = () => {
|
||||
setEditingId(null);
|
||||
setForm(emptyForm());
|
||||
setErr(null);
|
||||
setConfirmingDelete(false);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (f) => {
|
||||
setEditingId(f.id);
|
||||
setForm({
|
||||
investissement_id: String(f.investissement_pe_id),
|
||||
montant: f.montant,
|
||||
mode_reglement: f.mode_reglement,
|
||||
compte_id: f.compte_id || '',
|
||||
date_operation: f.date_operation,
|
||||
notes: f.notes || '',
|
||||
categorie: f.categorie || 'autre',
|
||||
});
|
||||
setErr(null);
|
||||
setConfirmingDelete(false);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const closeModal = () => { setModalOpen(false); setErr(null); setConfirmingDelete(false); };
|
||||
const { run: runSubmit, pending: submitting } = useAsyncAction();
|
||||
|
||||
const setField = (key, value) => {
|
||||
setForm(f => {
|
||||
if (key === 'mode_reglement') return { ...f, mode_reglement: value, compte_id: value === 'compte_courant' ? f.compte_id : '' };
|
||||
if (key === 'investissement_id') return { ...f, investissement_id: value, compte_id: '' };
|
||||
return { ...f, [key]: value };
|
||||
});
|
||||
};
|
||||
|
||||
const submit = async (e) => {
|
||||
e?.preventDefault?.();
|
||||
setErr(null);
|
||||
try {
|
||||
const payload = {
|
||||
investissement_pe_id: Number(form.investissement_id),
|
||||
montant: Number(form.montant),
|
||||
mode_reglement: form.mode_reglement,
|
||||
compte_id: form.mode_reglement === 'compte_courant' && form.compte_id ? Number(form.compte_id) : null,
|
||||
date_operation: form.date_operation,
|
||||
origine: 'manuel',
|
||||
categorie: form.categorie || 'autre',
|
||||
notes: form.notes || undefined,
|
||||
};
|
||||
if (editingId) await api.put(`/frais-operations/${editingId}`, payload);
|
||||
else await api.post('/frais-operations', payload);
|
||||
closeModal();
|
||||
await load();
|
||||
} catch (e) { setErr(e.message); }
|
||||
};
|
||||
|
||||
const onDelete = async () => {
|
||||
setErr(null);
|
||||
try {
|
||||
await api.del(`/frais-operations/${editingId}`);
|
||||
closeModal();
|
||||
await load();
|
||||
} catch (e) { setErr(e.message); }
|
||||
};
|
||||
|
||||
/* ── Rendu ── */
|
||||
if (platLoaded && !loading && plateformes.length === 0) return (
|
||||
<>
|
||||
<div className="topbar"><h2>Frais</h2></div>
|
||||
<EmptyState />
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="topbar">
|
||||
<h2><PageIcon name="frais" />Frais</h2>
|
||||
<button className="primary" onClick={openAdd}>+ Ajouter un frais</button>
|
||||
</div>
|
||||
|
||||
{/* ── Graphiques : courbe du cumul des frais + répartition par plateforme ── */}
|
||||
<div className="charts-row">
|
||||
<FraisCumulChart rows={fraisFiltered} />
|
||||
<FraisDistributionChart rows={fraisFiltered} groupBy={f => f.plateforme_nom || '—'} label="par Plateforme" />
|
||||
</div>
|
||||
|
||||
{/* ── KPIs ── */}
|
||||
<div className="dr-kpi-row">
|
||||
<div className="kpi">
|
||||
<div className="label">Frais payés</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
|
||||
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fmtEUR(fraisTotal)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="kpi">
|
||||
<div className="label">Frais de souscription</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
|
||||
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fmtEUR(fraisSouscriptionTotal)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="kpi">
|
||||
<div className="label">Frais de distribution</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
|
||||
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fmtEUR(fraisDistributionTotal)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="kpi">
|
||||
<div className="label">Nombre d'opérations</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
|
||||
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fraisFiltered.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="kpi">
|
||||
<div className="label">Frais moyen / opération</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
|
||||
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fmtEUR(fraisMoyen)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Onglets ── */}
|
||||
<div className="dr-tabs">
|
||||
<button className={`dr-tab${activeTab === 'plateformes' ? ' active' : ''}`}
|
||||
onClick={() => setActiveTab('plateformes')}>Plateformes</button>
|
||||
<button className={`dr-tab${activeTab === 'typologie' ? ' active' : ''}`}
|
||||
onClick={() => setActiveTab('typologie')}>Typologie</button>
|
||||
<button className={`dr-tab${activeTab === 'investissements' ? ' active' : ''}`}
|
||||
onClick={() => setActiveTab('investissements')}>Investissements</button>
|
||||
<button className={`dr-tab${activeTab === 'operations' ? ' active' : ''}`}
|
||||
onClick={() => setActiveTab('operations')}>Opérations</button>
|
||||
</div>
|
||||
|
||||
{/* ══ ONGLET PLATEFORMES ══ */}
|
||||
{activeTab === 'plateformes' && (
|
||||
<div style={{ padding: '0 24px' }}>
|
||||
<div className="card">
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
|
||||
<h3 style={{ margin: 0 }}>Répartition par plateforme</h3>
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Plateforme</th>
|
||||
{multiDetenteur && <th>Détenteur</th>}
|
||||
<th className="num">Montant</th>
|
||||
<th className="num">Nombre</th>
|
||||
<th className="num" style={{ minWidth: 120 }}>Poids</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{byPlateforme.length === 0 && (
|
||||
<tr><td colSpan={multiDetenteur ? 5 : 4} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>
|
||||
{loading ? 'Chargement…' : 'Aucun frais enregistré pour ces critères'}
|
||||
</td></tr>
|
||||
)}
|
||||
{byPlateforme.map(p => {
|
||||
const poids = fraisTotal !== 0 ? (p.montant / fraisTotal) * 100 : 0;
|
||||
return (
|
||||
<tr key={p.key}>
|
||||
<td>{p.label}</td>
|
||||
{multiDetenteur && <td className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>{p.detenteur}</td>}
|
||||
<td className="num">{fmtEUR(p.montant)}</td>
|
||||
<td className="num" style={{ color: 'var(--text-muted)', fontSize: 'var(--fs-sm)' }}>{p.count}</td>
|
||||
<td className="num">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, justifyContent: 'flex-end' }}>
|
||||
<div style={{ width: 48, height: 5, borderRadius: 3, background: 'var(--surface-2)', overflow: 'hidden' }}>
|
||||
<div style={{ width: `${Math.max(0, poids)}%`, height: '100%', background: 'var(--primary)', borderRadius: 3 }} />
|
||||
</div>
|
||||
<span style={{ minWidth: 36, textAlign: 'right', fontSize: 'var(--fs-sm)' }}>
|
||||
{poids.toFixed(1)} %
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
{byPlateforme.length > 1 && (
|
||||
<tfoot>
|
||||
<tr style={{ borderTop: '2px solid var(--border)', fontWeight: 700, background: 'var(--surface-2)' }}>
|
||||
<td colSpan={multiDetenteur ? 2 : 1}>Total — {byPlateforme.length} plateformes</td>
|
||||
<td className="num">{fmtEUR(fraisTotal)}</td>
|
||||
<td className="num" style={{ color: 'var(--text-muted)', fontSize: 'var(--fs-sm)' }}>{fraisFiltered.length}</td>
|
||||
<td className="num"><span style={{ fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>100 %</span></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
)}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ══ ONGLET TYPOLOGIE ══ */}
|
||||
{activeTab === 'typologie' && (
|
||||
<div style={{ padding: '0 24px' }}>
|
||||
<div className="card">
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
|
||||
<h3 style={{ margin: 0 }}>Répartition par catégorie</h3>
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Catégorie</th>
|
||||
<th className="num">Montant</th>
|
||||
<th className="num">Nombre</th>
|
||||
<th className="num" style={{ minWidth: 120 }}>Poids</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{byCategorie.length === 0 && (
|
||||
<tr><td colSpan={4} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>
|
||||
{loading ? 'Chargement…' : 'Aucun frais enregistré pour ces critères'}
|
||||
</td></tr>
|
||||
)}
|
||||
{byCategorie.map(c => {
|
||||
const poids = fraisTotal !== 0 ? (c.montant / fraisTotal) * 100 : 0;
|
||||
return (
|
||||
<tr key={c.key}>
|
||||
<td>{c.label}</td>
|
||||
<td className="num">{fmtEUR(c.montant)}</td>
|
||||
<td className="num" style={{ color: 'var(--text-muted)', fontSize: 'var(--fs-sm)' }}>{c.count}</td>
|
||||
<td className="num">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, justifyContent: 'flex-end' }}>
|
||||
<div style={{ width: 48, height: 5, borderRadius: 3, background: 'var(--surface-2)', overflow: 'hidden' }}>
|
||||
<div style={{ width: `${Math.max(0, poids)}%`, height: '100%', background: 'var(--primary)', borderRadius: 3 }} />
|
||||
</div>
|
||||
<span style={{ minWidth: 36, textAlign: 'right', fontSize: 'var(--fs-sm)' }}>
|
||||
{poids.toFixed(1)} %
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
{byCategorie.length > 1 && (
|
||||
<tfoot>
|
||||
<tr style={{ borderTop: '2px solid var(--border)', fontWeight: 700, background: 'var(--surface-2)' }}>
|
||||
<td>Total — {byCategorie.length} catégories</td>
|
||||
<td className="num">{fmtEUR(fraisTotal)}</td>
|
||||
<td className="num" style={{ color: 'var(--text-muted)', fontSize: 'var(--fs-sm)' }}>{fraisFiltered.length}</td>
|
||||
<td className="num"><span style={{ fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>100 %</span></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
)}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ══ ONGLET INVESTISSEMENTS — liste des frais par deal ══ */}
|
||||
{activeTab === 'investissements' && (
|
||||
<div style={{ padding: '0 24px' }}>
|
||||
<div className="card">
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
|
||||
<h3 style={{ margin: 0 }}>Frais par investissement</h3>
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Plateforme</th>
|
||||
{multiDetenteur && <th>Détenteur</th>}
|
||||
<th>Deal</th>
|
||||
<th className="num">Souscription</th>
|
||||
<th className="num">Distribution</th>
|
||||
<th className="num">Total Frais</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{byInvestissement.length === 0 && (
|
||||
<tr><td colSpan={multiDetenteur ? 6 : 5} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>
|
||||
{loading ? 'Chargement…' : 'Aucun frais enregistré pour ces critères'}
|
||||
</td></tr>
|
||||
)}
|
||||
{byInvestissement.map(d => (
|
||||
<tr key={d.key}>
|
||||
<td>{d.plateforme}</td>
|
||||
{multiDetenteur && <td className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>{d.detenteur}</td>}
|
||||
<td>{d.projet}</td>
|
||||
<td className="num">{fmtEUR(d.souscription)}</td>
|
||||
<td className="num">{fmtEUR(d.distribution)}</td>
|
||||
<td className="num" style={{ fontWeight: 600 }}>{fmtEUR(d.total)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
{byInvestissement.length > 1 && (
|
||||
<tfoot>
|
||||
<tr style={{ borderTop: '2px solid var(--border)', fontWeight: 700, background: 'var(--surface-2)' }}>
|
||||
<td colSpan={multiDetenteur ? 3 : 2}>Total — {byInvestissement.length} investissements</td>
|
||||
<td className="num">{fmtEUR(byInvestissement.reduce((s, d) => s + d.souscription, 0))}</td>
|
||||
<td className="num">{fmtEUR(byInvestissement.reduce((s, d) => s + d.distribution, 0))}</td>
|
||||
<td className="num">{fmtEUR(byInvestissement.reduce((s, d) => s + d.total, 0))}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
)}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ══ ONGLET OPÉRATIONS — liste détaillée (CRUD) ══ */}
|
||||
{activeTab === 'operations' && (
|
||||
<div style={{ padding: '0 24px' }}>
|
||||
<div className="card">
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
|
||||
<h3 style={{ margin: 0 }}>Détail des opérations</h3>
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th><th>Plateforme</th><th>Deal</th><th className="num">Montant</th>
|
||||
<th>Catégorie</th><th>Prélèvement</th><th>Compte</th><th>Notes</th>
|
||||
<th style={{ width: 36 }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pagedFrais.length === 0 && (
|
||||
<tr><td colSpan={9} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>
|
||||
{loading ? 'Chargement…' : 'Aucun frais enregistré pour ces critères'}
|
||||
</td></tr>
|
||||
)}
|
||||
{pagedFrais.map(f => (
|
||||
<tr key={f.id} style={{ cursor: 'pointer' }} title="Modifier ce frais" onClick={() => openEdit(f)}>
|
||||
<td>{fmtDate(f.date_operation)}</td>
|
||||
<td>{f.plateforme_nom}</td>
|
||||
<td>{f.nom_deal}</td>
|
||||
<td className="num">{fmtEUR(f.montant)}</td>
|
||||
<td>{FRAIS_CATEGORIE_LABELS[f.categorie] || f.categorie}</td>
|
||||
<td style={{ fontSize: 'var(--fs-sm)' }}>{FRAIS_MODE_LABELS[f.mode_reglement] || f.mode_reglement}</td>
|
||||
<td className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>{f.compte_nom || '—'}</td>
|
||||
<td className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>{f.notes || '—'}</td>
|
||||
<td style={{ width: 36, textAlign: 'center' }} onClick={e => e.stopPropagation()}>
|
||||
<button
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px 6px', borderRadius: 4, color: 'var(--text-muted)', lineHeight: 1, fontSize: 16 }}
|
||||
title="Modifier"
|
||||
onClick={() => openEdit(f)}>⋮</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<Pagination
|
||||
page={page} setPage={setPage}
|
||||
pageSize={pageSize} setPageSize={setPageSize}
|
||||
totalPages={totalPages} totalItems={totalItems}
|
||||
PAGE_SIZES={PAGE_SIZES}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FraisFormModal
|
||||
open={modalOpen}
|
||||
onClose={closeModal}
|
||||
editingId={editingId}
|
||||
form={form}
|
||||
onFieldChange={setField}
|
||||
err={err}
|
||||
onSubmit={e => runSubmit(submit, e)}
|
||||
onDelete={() => runSubmit(onDelete)}
|
||||
submitting={submitting}
|
||||
comptesInvestisseur={comptesInvestisseur}
|
||||
investissementOptions={investissementOptions}
|
||||
confirmingDelete={confirmingDelete}
|
||||
setConfirmingDelete={setConfirmingDelete}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import InvestissementFormModal from '../components/InvestissementFormModal.jsx';
|
||||
import { dayOfMonth } from '../utils/dateEcheances.js';
|
||||
import { fmtEUR, fmtPct, fmtDate, today, fmtOctets } from '../utils/format.js';
|
||||
import { xirr } from '../utils/xirr.js';
|
||||
import { useAsyncAction } from '../hooks/useAsyncAction.js';
|
||||
|
||||
const emptyForm = {
|
||||
investisseur_id: '', plateforme_id: '', nom_projet: '', emetteur: '',
|
||||
@@ -110,6 +111,12 @@ export default function InvestissementDetail() {
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [form, setForm] = useState(emptyForm);
|
||||
const [err, setErr] = useState(null);
|
||||
// Gardes anti-double-soumission (cf. useAsyncAction) — une par modale indépendante, pour ne
|
||||
// désactiver que les boutons de la modale concernée pendant sa propre requête.
|
||||
const { run: runSubmit, pending: submitting } = useAsyncAction();
|
||||
const { run: runSubmitRemb, pending: submittingRemb } = useAsyncAction();
|
||||
const { run: runSubmitFrais, pending: submittingFrais } = useAsyncAction();
|
||||
const { run: runSubmitReinv, pending: submittingReinv } = useAsyncAction();
|
||||
// Mémorise la dernière souscription valide (utile pour isAutoCalc malgré les onChange "" intermédiaires)
|
||||
const lastValidSouscriptionRef = useRef('');
|
||||
|
||||
@@ -2534,9 +2541,10 @@ export default function InvestissementDetail() {
|
||||
title={revisionEditId ? 'Corriger la révision' : 'Réviser les conditions du prêt'}
|
||||
onClose={closeRevisionModal}
|
||||
width={520}
|
||||
busy={revisionSaving}
|
||||
footer={
|
||||
<>
|
||||
<button className="ghost" onClick={closeRevisionModal}>Annuler</button>
|
||||
<button className="ghost" disabled={revisionSaving} onClick={closeRevisionModal}>Annuler</button>
|
||||
<button onClick={submitRevision} disabled={revisionSaving}>
|
||||
{revisionSaving ? 'Enregistrement…' : (revisionEditId ? 'Enregistrer la correction' : 'Enregistrer la révision')}
|
||||
</button>
|
||||
@@ -2634,8 +2642,9 @@ export default function InvestissementDetail() {
|
||||
open={modalOpen}
|
||||
editingId={id}
|
||||
onClose={close}
|
||||
onSubmit={submit}
|
||||
onDelete={onDelete}
|
||||
onSubmit={e => runSubmit(submit, e)}
|
||||
onDelete={() => runSubmit(onDelete)}
|
||||
submitting={submitting}
|
||||
confirmingDelete={confirmingInvDelete}
|
||||
setConfirmingDelete={setConfirmingInvDelete}
|
||||
form={form}
|
||||
@@ -2717,8 +2726,9 @@ export default function InvestissementDetail() {
|
||||
open={rembModalOpen}
|
||||
editingId={editingRembId}
|
||||
onClose={closeRembModal}
|
||||
onSubmit={submitRemb}
|
||||
onDelete={deleteRemb}
|
||||
onSubmit={e => runSubmitRemb(submitRemb, e)}
|
||||
onDelete={() => runSubmitRemb(deleteRemb)}
|
||||
submitting={submittingRemb}
|
||||
confirmingDelete={confirmingRembDelete}
|
||||
setConfirmingDelete={setConfirmingRembDelete}
|
||||
err={rembErr}
|
||||
@@ -2738,27 +2748,30 @@ export default function InvestissementDetail() {
|
||||
title="Ajouter un réinvestissement"
|
||||
onClose={closeReinvModal}
|
||||
width={480}
|
||||
busy={submittingReinv}
|
||||
footer={
|
||||
reinvTab === 'manuel' ? (
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||
<button onClick={closeReinvModal}>Annuler</button>
|
||||
<button className="primary" onClick={submitReinv}>Enregistrer</button>
|
||||
<button disabled={submittingReinv} onClick={closeReinvModal}>Annuler</button>
|
||||
<button className="primary" disabled={submittingReinv} onClick={() => runSubmitReinv(submitReinv)}>
|
||||
{submittingReinv ? 'Enregistrement…' : 'Enregistrer'}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||
<button onClick={closeReinvModal}>Annuler</button>
|
||||
<button disabled={submittingReinv} onClick={closeReinvModal}>Annuler</button>
|
||||
{!autoReinvActive ? (
|
||||
<button className="primary" onClick={async () => {
|
||||
<button className="primary" disabled={submittingReinv} onClick={() => runSubmitReinv(async () => {
|
||||
await api.put(`/investissements/${id}/auto-reinvest`, { active: true });
|
||||
await load();
|
||||
closeReinvModal();
|
||||
}}>Activer</button>
|
||||
})}>{submittingReinv ? '…' : 'Activer'}</button>
|
||||
) : (
|
||||
<button className="danger" onClick={async () => {
|
||||
<button className="danger" disabled={submittingReinv} onClick={() => runSubmitReinv(async () => {
|
||||
await api.put(`/investissements/${id}/auto-reinvest`, { active: false });
|
||||
await load();
|
||||
closeReinvModal();
|
||||
}}>Désactiver</button>
|
||||
})}>{submittingReinv ? '…' : 'Désactiver'}</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
@@ -2781,7 +2794,7 @@ export default function InvestissementDetail() {
|
||||
</div>
|
||||
|
||||
{reinvTab === 'manuel' ? (
|
||||
<form onSubmit={submitReinv}>
|
||||
<form onSubmit={e => runSubmitReinv(submitReinv, e)}>
|
||||
<p className="text-muted" style={{ marginTop: 0, marginBottom: 16, fontSize: 13 }}>
|
||||
Un réinvestissement ajoute du capital à ce prêt à une date donnée. La projection
|
||||
des intérêts sera recalculée à partir de cette date.
|
||||
@@ -2854,9 +2867,10 @@ export default function InvestissementDetail() {
|
||||
title="Modifier la date de la 1ère échéance"
|
||||
onClose={() => setEditDpeModal(false)}
|
||||
width={420}
|
||||
busy={editDpeSaving}
|
||||
footer={
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||
<button onClick={() => setEditDpeModal(false)}>Annuler</button>
|
||||
<button disabled={editDpeSaving} onClick={() => setEditDpeModal(false)}>Annuler</button>
|
||||
<button className="primary" onClick={saveEditDpe} disabled={editDpeSaving}>
|
||||
{editDpeSaving ? 'Enregistrement…' : 'Enregistrer'}
|
||||
</button>
|
||||
@@ -2886,6 +2900,7 @@ export default function InvestissementDetail() {
|
||||
title="Traitement en masse des remboursements"
|
||||
onClose={() => { if (!bulkRembProcessing) setBulkRembModal(false); }}
|
||||
width={560}
|
||||
busy={bulkRembProcessing}
|
||||
footer={(() => {
|
||||
const selectedCount = bulkRembItems.filter(it => it.selected).length;
|
||||
return bulkRembDone ? (
|
||||
@@ -3279,7 +3294,8 @@ export default function InvestissementDetail() {
|
||||
form={fraisForm}
|
||||
onFieldChange={setFraisField}
|
||||
err={fraisErr}
|
||||
onSubmit={submitFrais}
|
||||
onSubmit={e => runSubmitFrais(submitFrais, e)}
|
||||
submitting={submittingFrais}
|
||||
comptesInvestisseur={comptesFraisInvestisseur}
|
||||
locked
|
||||
/>
|
||||
@@ -3297,9 +3313,10 @@ export default function InvestissementDetail() {
|
||||
title="Déclarer une perte définitive"
|
||||
onClose={closePerteModal}
|
||||
width={520}
|
||||
busy={perteSaving}
|
||||
footer={
|
||||
<>
|
||||
<button className="ghost" onClick={closePerteModal}>Annuler</button>
|
||||
<button className="ghost" disabled={perteSaving} onClick={closePerteModal}>Annuler</button>
|
||||
<button className="danger" onClick={submitPerte} disabled={perteSaving}>
|
||||
{perteSaving ? 'Enregistrement…' : 'Déclarer la perte'}
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,624 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate, useParams } 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 PageIcon from '../components/PageIcon.jsx';
|
||||
import InvestissementPeFormModal from '../components/InvestissementPeFormModal.jsx';
|
||||
import FraisFormModal from '../components/FraisFormModal.jsx';
|
||||
import { FRAIS_CATEGORIE_LABELS } from '../components/MouvementsShared.jsx';
|
||||
import { useAsyncAction } from '../hooks/useAsyncAction.js';
|
||||
import { fmtEUR, fmtDate, fmtOctets, today } from '../utils/format.js';
|
||||
import { emptyDealForm, AUTO_VAL_NOTE } from './InvestissementsPe.jsx';
|
||||
|
||||
/**
|
||||
* Fiche détaillée d'un deal Private Equity (chantier "fiche détail PE", 17/09/26 — demande
|
||||
* Olivier : "une page investissement détail adapté qui reprend un peu l'esprit de crowdlending
|
||||
* ... il faut que je vois tous les remboursements, tous les frais associés et que j'aie la
|
||||
* possibilité de stocker des documents"). Première passe volontairement sobre (structure posée,
|
||||
* à enrichir ensuite) — pas encore de reprise des notions Déploiement / TVPI du sous-jacent /
|
||||
* Obligation / Valeur totale cible visibles sur les captures Fundora d'Olivier, toujours hors
|
||||
* périmètre comme convenu lors du chantier précédent.
|
||||
*
|
||||
* Route : /investissements/:id, branchée ici pour tout workspace non-crowdlending par
|
||||
* InvestissementDetailRouter (App.jsx) — miroir de InvestissementDetail.jsx (crowdlending), en
|
||||
* beaucoup plus court : pas d'échéancier, de XIRR, de révisions de prêt ni de réinvestissements
|
||||
* (notions sans équivalent PE à ce stade).
|
||||
*
|
||||
* Modifier l'investissement réutilise directement InvestissementPeFormModal (le même assistant
|
||||
* par étapes que la liste), y compris son étape "Frais" (souscription/gestion réglés à la
|
||||
* source) — le bloc "Frais d'opération" ci-dessous gère lui les frais complémentaires
|
||||
* (gestion/distribution/autre), exactement comme côté crowdlending : les lignes 'souscription'
|
||||
* et 'gestion'-à-l'origine-souscription y apparaissent en lecture seule, redirigeant vers
|
||||
* "Modifier l'investissement" plutôt que vers la modale FraisFormModal (qui n'propose pas la
|
||||
* catégorie 'souscription' dans son sélecteur).
|
||||
*/
|
||||
|
||||
const fmtMultiple = (n) => (n != null ? `x${n.toFixed(2).replace('.', ',')}` : '—');
|
||||
|
||||
const STATUT_LABELS = {
|
||||
en_attente: 'En attente',
|
||||
valide: 'Validé',
|
||||
cloture: 'Clôturé',
|
||||
perte_definitive: 'Perte définitive',
|
||||
};
|
||||
|
||||
const FRAIS_MODE_LABELS = {
|
||||
source: 'À la source',
|
||||
portefeuille: 'Porte-monnaie',
|
||||
compte_courant: 'Compte courant',
|
||||
remboursement: 'Retenu sur remboursement',
|
||||
};
|
||||
|
||||
const emptyValForm = { date_valorisation: today(), valorisation_nette: '', notes: '' };
|
||||
const emptyFraisForm = { montant: '', mode_reglement: 'source', compte_id: '', date_operation: today(), categorie: 'gestion', notes: '' };
|
||||
|
||||
export default function InvestissementPeDetail() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { investisseurs } = useInvestisseur();
|
||||
const { active: activeWorkspace } = useWorkspace() || {};
|
||||
|
||||
const [deal, setDeal] = useState(null);
|
||||
const [allPlats, setAllPlats] = useState([]);
|
||||
const [remboursements, setRemboursements] = useState([]);
|
||||
const [fraisOps, setFraisOps] = useState([]);
|
||||
const [valorisations, setValorisations] = useState([]);
|
||||
const [documents, setDocuments] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [err, setErr] = useState(null);
|
||||
|
||||
const plats = allPlats.filter(p => (p.workspaces || []).some(w => w.id === activeWorkspace?.id));
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [d, pl, remb, frais, val, docs] = await Promise.all([
|
||||
api.get(`/investissements-pe/${id}`),
|
||||
api.get('/plateformes'),
|
||||
api.get('/remboursements'),
|
||||
api.get('/frais-operations', { investissement_pe_id: id }),
|
||||
api.get(`/investissements-pe/${id}/valorisations`),
|
||||
api.get('/documents', { entity_type: 'investissement_pe', entity_id: id }),
|
||||
]);
|
||||
setDeal(d);
|
||||
setAllPlats(pl);
|
||||
setRemboursements(remb.filter(r => String(r.investissement_pe_id) === String(id)));
|
||||
setFraisOps(frais);
|
||||
setValorisations(val);
|
||||
setDocuments(docs);
|
||||
} catch (e) {
|
||||
setErr(e.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { load(); /* eslint-disable-next-line */ }, [id]);
|
||||
|
||||
/* ── Modifier l'investissement (réutilise le même assistant que la liste) ── */
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [form, setForm] = useState(emptyDealForm);
|
||||
const [formErr, setFormErr] = useState(null);
|
||||
const [comptesInvestisseur, setComptesInvestisseur] = useState([]);
|
||||
const { run: runSubmit, pending: submitting } = useAsyncAction();
|
||||
const [deleteConfirm, setDeleteConfirm] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!form.investisseur_id || !modalOpen) { setComptesInvestisseur([]); return; }
|
||||
api.get(`/investissements/comptes-par-investisseur/${form.investisseur_id}`)
|
||||
.then(setComptesInvestisseur)
|
||||
.catch(() => setComptesInvestisseur([]));
|
||||
}, [form.investisseur_id, modalOpen]);
|
||||
|
||||
const openEditDeal = () => {
|
||||
if (!deal) return;
|
||||
const entree = fraisOps.find(f => f.categorie === 'souscription');
|
||||
const gestion = fraisOps.find(f => f.categorie === 'gestion' && f.origine === 'souscription');
|
||||
const autoVal = valorisations.find(v => v.notes === AUTO_VAL_NOTE);
|
||||
setForm({
|
||||
...emptyDealForm,
|
||||
plateforme_id: String(deal.plateforme_id || ''),
|
||||
investisseur_id: String(deal.investisseur_id || ''),
|
||||
nom_deal: deal.nom_deal,
|
||||
strategie: deal.strategie || '',
|
||||
date_souscription: deal.date_souscription,
|
||||
duree_annees: deal.duree_mois ? String(Math.round((deal.duree_mois / 12) * 10) / 10) : '',
|
||||
montant_investi: String(deal.montant_investi ?? ''),
|
||||
objectif: deal.objectif || '',
|
||||
statut: deal.statut,
|
||||
reference: deal.reference || '',
|
||||
notes: deal.notes || '',
|
||||
valorisation_initiale_id: autoVal ? autoVal.id : null,
|
||||
...(entree ? {
|
||||
frais_entree_valeur: String(entree.montant), frais_entree_mode_reglement: entree.mode_reglement,
|
||||
frais_entree_compte_id: entree.compte_id ? String(entree.compte_id) : '', frais_entree_id: entree.id,
|
||||
} : {}),
|
||||
...(gestion ? {
|
||||
frais_gestion_valeur: String(gestion.montant), frais_gestion_mode_reglement: gestion.mode_reglement,
|
||||
frais_gestion_compte_id: gestion.compte_id ? String(gestion.compte_id) : '', frais_gestion_id: gestion.id,
|
||||
} : {}),
|
||||
});
|
||||
setFormErr(null);
|
||||
setModalOpen(true);
|
||||
};
|
||||
const closeEditDeal = () => { setModalOpen(false); setFormErr(null); };
|
||||
|
||||
const investisseurForPlat = (platId) => {
|
||||
const plat = plats.find(p => String(p.id) === String(platId));
|
||||
return plat?.investisseur_id ? String(plat.investisseur_id) : String(deal?.investisseur_id || '');
|
||||
};
|
||||
|
||||
const computeFraisTTC = (modeSaisie, valeur) => {
|
||||
if (valeur === '' || Number(valeur) <= 0) return 0;
|
||||
return Math.round((modeSaisie === 'pourcentage'
|
||||
? Number(form.montant_investi) * Number(valeur) / 100
|
||||
: Number(valeur)) * 100) / 100;
|
||||
};
|
||||
|
||||
const upsertFrais = async (has, ttc, categorie, modeReglement, compteId, fraisId) => {
|
||||
try {
|
||||
if (has) {
|
||||
const payload = {
|
||||
investissement_pe_id: Number(id),
|
||||
montant: ttc,
|
||||
mode_reglement: modeReglement,
|
||||
compte_id: modeReglement === 'compte_courant' && compteId ? Number(compteId) : null,
|
||||
date_operation: form.date_souscription,
|
||||
origine: 'souscription',
|
||||
categorie,
|
||||
};
|
||||
if (fraisId) await api.put(`/frais-operations/${fraisId}`, payload);
|
||||
else await api.post('/frais-operations', payload);
|
||||
} else if (fraisId) {
|
||||
await api.del(`/frais-operations/${fraisId}`);
|
||||
}
|
||||
} catch { /* non bloquant */ }
|
||||
};
|
||||
|
||||
const syncValorisationInitiale = async (totalFraisSource, valorisationInitialeId) => {
|
||||
try {
|
||||
if (valorisationInitialeId) await api.del(`/investissements-pe/${id}/valorisations/${valorisationInitialeId}`);
|
||||
if (totalFraisSource > 0) {
|
||||
const initiale = Math.max(0, Math.round((Number(form.montant_investi) - totalFraisSource) * 100) / 100);
|
||||
await api.post(`/investissements-pe/${id}/valorisations`, {
|
||||
date_valorisation: form.date_souscription, valorisation_nette: initiale, notes: AUTO_VAL_NOTE,
|
||||
});
|
||||
}
|
||||
} catch { /* non bloquant */ }
|
||||
};
|
||||
|
||||
const submitDeal = async (e) => {
|
||||
e?.preventDefault?.(); setFormErr(null);
|
||||
|
||||
const platFraisType = plats.find(p => String(p.id) === String(form.plateforme_id))?.frais_plateforme;
|
||||
const fraisApplicable = !!platFraisType && platFraisType !== 'aucun';
|
||||
const hasFraisEntree = fraisApplicable && form.frais_entree_valeur !== '' && Number(form.frais_entree_valeur) > 0;
|
||||
const hasFraisGestion = fraisApplicable && form.frais_gestion_valeur !== '' && Number(form.frais_gestion_valeur) > 0;
|
||||
const fraisEntreeTTC = hasFraisEntree ? computeFraisTTC(form.frais_entree_mode_saisie, form.frais_entree_valeur) : 0;
|
||||
const fraisGestionTTC = hasFraisGestion ? computeFraisTTC(form.frais_gestion_mode_saisie, form.frais_gestion_valeur) : 0;
|
||||
const totalFraisSource =
|
||||
(hasFraisEntree && form.frais_entree_mode_reglement === 'source' ? fraisEntreeTTC : 0) +
|
||||
(hasFraisGestion && form.frais_gestion_mode_reglement === 'source' ? fraisGestionTTC : 0);
|
||||
|
||||
const payload = {
|
||||
plateforme_id: Number(form.plateforme_id),
|
||||
investisseur_id: Number(form.investisseur_id) || undefined,
|
||||
nom_deal: form.nom_deal,
|
||||
strategie: form.strategie || undefined,
|
||||
date_souscription: form.date_souscription,
|
||||
duree_mois: form.duree_annees ? Math.round(Number(form.duree_annees) * 12) : undefined,
|
||||
montant_investi: Number(form.montant_investi),
|
||||
objectif: form.objectif || undefined,
|
||||
statut: form.statut,
|
||||
reference: form.reference || undefined,
|
||||
notes: form.notes || undefined,
|
||||
};
|
||||
try {
|
||||
await api.put(`/investissements-pe/${id}`, payload);
|
||||
await upsertFrais(hasFraisEntree, fraisEntreeTTC, 'souscription', form.frais_entree_mode_reglement, form.frais_entree_compte_id, form.frais_entree_id);
|
||||
await upsertFrais(hasFraisGestion, fraisGestionTTC, 'gestion', form.frais_gestion_mode_reglement, form.frais_gestion_compte_id, form.frais_gestion_id);
|
||||
await syncValorisationInitiale(totalFraisSource, form.valorisation_initiale_id);
|
||||
closeEditDeal();
|
||||
await load();
|
||||
} catch (e) { setFormErr(e.message); }
|
||||
};
|
||||
|
||||
const onDeleteDeal = async () => {
|
||||
try {
|
||||
await api.del(`/investissements-pe/${id}`);
|
||||
navigate('/investissements');
|
||||
} catch (e) { setErr(e.message); }
|
||||
};
|
||||
|
||||
/* ── Valorisations ── */
|
||||
const [valForm, setValForm] = useState(emptyValForm);
|
||||
const [valErr, setValErr] = useState(null);
|
||||
const [valDeleteTarget, setValDeleteTarget] = useState(null);
|
||||
const { run: runValSubmit, pending: valSubmitting } = useAsyncAction();
|
||||
|
||||
const submitVal = async (e) => {
|
||||
e?.preventDefault?.(); setValErr(null);
|
||||
try {
|
||||
await api.post(`/investissements-pe/${id}/valorisations`, {
|
||||
date_valorisation: valForm.date_valorisation,
|
||||
valorisation_nette: Number(valForm.valorisation_nette),
|
||||
notes: valForm.notes || undefined,
|
||||
});
|
||||
setValForm(emptyValForm);
|
||||
await load();
|
||||
} catch (e) { setValErr(e.message); }
|
||||
};
|
||||
const onDeleteVal = async () => {
|
||||
if (!valDeleteTarget) return;
|
||||
try {
|
||||
await api.del(`/investissements-pe/${id}/valorisations/${valDeleteTarget.id}`);
|
||||
setValDeleteTarget(null);
|
||||
await load();
|
||||
} catch (e) { setValErr(e.message); }
|
||||
};
|
||||
|
||||
/* ── Frais d'opération complémentaires (gestion/distribution/autre) ── */
|
||||
const [fraisModalOpen, setFraisModalOpen] = useState(false);
|
||||
const [fraisEditingId, setFraisEditingId] = useState(null);
|
||||
const [fraisForm, setFraisForm] = useState(emptyFraisForm);
|
||||
const [fraisErr, setFraisErr] = useState(null);
|
||||
const [fraisConfirmingDelete, setFraisConfirmingDelete] = useState(false);
|
||||
const { run: runFraisSubmit, pending: fraisSubmitting } = useAsyncAction();
|
||||
|
||||
const openAddFrais = () => {
|
||||
setFraisEditingId(null);
|
||||
setFraisForm({ ...emptyFraisForm, date_operation: today() });
|
||||
setFraisErr(null);
|
||||
setFraisConfirmingDelete(false);
|
||||
setFraisModalOpen(true);
|
||||
};
|
||||
const openEditFrais = (f) => {
|
||||
setFraisEditingId(f.id);
|
||||
setFraisForm({
|
||||
montant: f.montant, mode_reglement: f.mode_reglement, compte_id: f.compte_id || '',
|
||||
date_operation: f.date_operation, categorie: f.categorie || 'autre', notes: f.notes || '',
|
||||
});
|
||||
setFraisErr(null);
|
||||
setFraisConfirmingDelete(false);
|
||||
setFraisModalOpen(true);
|
||||
};
|
||||
const closeFraisModal = () => { setFraisModalOpen(false); setFraisErr(null); setFraisConfirmingDelete(false); };
|
||||
|
||||
const submitFrais = async (e) => {
|
||||
e?.preventDefault?.(); setFraisErr(null);
|
||||
try {
|
||||
const payload = {
|
||||
investissement_pe_id: Number(id),
|
||||
montant: Number(fraisForm.montant),
|
||||
mode_reglement: fraisForm.mode_reglement,
|
||||
compte_id: fraisForm.mode_reglement === 'compte_courant' && fraisForm.compte_id ? Number(fraisForm.compte_id) : null,
|
||||
date_operation: fraisForm.date_operation,
|
||||
origine: 'manuel',
|
||||
categorie: fraisForm.categorie || 'autre',
|
||||
notes: fraisForm.notes || undefined,
|
||||
};
|
||||
if (fraisEditingId) await api.put(`/frais-operations/${fraisEditingId}`, payload);
|
||||
else await api.post('/frais-operations', payload);
|
||||
closeFraisModal();
|
||||
await load();
|
||||
} catch (e) { setFraisErr(e.message); }
|
||||
};
|
||||
const onDeleteFrais = async () => {
|
||||
try {
|
||||
await api.del(`/frais-operations/${fraisEditingId}`);
|
||||
closeFraisModal();
|
||||
await load();
|
||||
} catch (e) { setFraisErr(e.message); }
|
||||
};
|
||||
|
||||
/* ── Documents (réutilise le système générique /documents, entity_type 'investissement_pe',
|
||||
* porté depuis InvestissementDetail.jsx en version simplifiée : bouton "+ Ajouter" direct
|
||||
* plutôt que menu ⋮, pas d'action "tout supprimer" pour cette première passe) ── */
|
||||
const [docsErr, setDocsErr] = useState(null);
|
||||
const [uploadingDoc, setUploadingDoc] = useState(false);
|
||||
const [renamingDocId, setRenamingDocId] = useState(null);
|
||||
const [renameValue, setRenameValue] = useState('');
|
||||
const [docDeleteTarget, setDocDeleteTarget] = useState(null);
|
||||
const docFileInputRef = useRef(null);
|
||||
|
||||
const refreshDocuments = async () => {
|
||||
try { setDocuments(await api.get('/documents', { entity_type: 'investissement_pe', entity_id: id })); }
|
||||
catch { /* la fiche reste utilisable même si le rafraîchissement échoue */ }
|
||||
};
|
||||
|
||||
const handleDocFileChange = async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = '';
|
||||
if (!file) return;
|
||||
setUploadingDoc(true); setDocsErr(null);
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
fd.append('entity_type', 'investissement_pe');
|
||||
fd.append('entity_id', id);
|
||||
fd.append('categorie', 'investissement_pe');
|
||||
fd.append('nom_affichage', file.name.replace(/\.[^./\\]+$/, ''));
|
||||
await api.postForm('/documents', fd);
|
||||
await refreshDocuments();
|
||||
} catch (e) { setDocsErr(e.message); }
|
||||
finally { setUploadingDoc(false); }
|
||||
};
|
||||
const handleDocRenameStart = (doc) => { setRenamingDocId(doc.id); setRenameValue(doc.nom_affichage); };
|
||||
const handleDocRenameCommit = async (docId) => {
|
||||
const value = renameValue.trim();
|
||||
setRenamingDocId(null);
|
||||
const current = documents.find(d => d.id === docId);
|
||||
if (!value || !current || value === current.nom_affichage) return;
|
||||
try { await api.put(`/documents/${docId}`, { nom_affichage: value }); await refreshDocuments(); }
|
||||
catch (e) { setDocsErr(e.message); }
|
||||
};
|
||||
const handleDocDownload = async (doc) => {
|
||||
try {
|
||||
const blob = await api.blob(`/documents/${doc.id}/download`);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url; a.download = `${doc.nom_affichage}.${doc.extension}`; a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (e) { setDocsErr(e.message); }
|
||||
};
|
||||
const handleDocDelete = async () => {
|
||||
if (!docDeleteTarget) return;
|
||||
try { await api.del(`/documents/${docDeleteTarget.id}`); setDocDeleteTarget(null); await refreshDocuments(); }
|
||||
catch (e) { setDocsErr(e.message); }
|
||||
};
|
||||
|
||||
if (loading) return <div style={{ padding: 32 }}>Chargement…</div>;
|
||||
if (!deal) return (
|
||||
<>
|
||||
<div className="topbar"><h2>Investissement introuvable</h2></div>
|
||||
{err && <div className="error" style={{ margin: '0 24px' }}>{err}</div>}
|
||||
</>
|
||||
);
|
||||
|
||||
const totalFrais = fraisOps.reduce((s, f) => s + (f.montant || 0), 0);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="topbar">
|
||||
<button onClick={() => navigate('/investissements')} style={{ marginRight: 12 }}>← Retour</button>
|
||||
<h2><PageIcon name="investissement" />{deal.nom_deal}</h2>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button onClick={openEditDeal}>Modifier</button>
|
||||
<button className="danger" onClick={() => setDeleteConfirm(true)}>Supprimer</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{err && <div className="error" style={{ margin: '0 24px 16px' }}>{err}</div>}
|
||||
|
||||
<div style={{ padding: '0 24px' }}>
|
||||
{/* ── Résumé ── */}
|
||||
<div className="card" style={{ marginBottom: 16 }}>
|
||||
<div className="dr-kpi-row" style={{ marginBottom: 0 }}>
|
||||
<div className="kpi"><div className="label">Montant investi</div>
|
||||
<div style={{ fontSize: '1.2rem', fontWeight: 700, marginTop: 4 }}>{fmtEUR(deal.montant_investi)}</div></div>
|
||||
<div className="kpi"><div className="label">Valorisation actuelle</div>
|
||||
<div style={{ fontSize: '1.2rem', fontWeight: 700, marginTop: 4 }}>{fmtEUR(deal.derniere_valorisation ?? deal.montant_investi)}</div></div>
|
||||
<div className="kpi"><div className="label">TVPI</div>
|
||||
<div style={{ fontSize: '1.2rem', fontWeight: 700, marginTop: 4 }}>{fmtMultiple(deal.tvpi)}</div></div>
|
||||
<div className="kpi"><div className="label">Distributions cumulées</div>
|
||||
<div style={{ fontSize: '1.2rem', fontWeight: 700, marginTop: 4 }}>{fmtEUR(deal.distributions_cumulees)}</div></div>
|
||||
<div className="kpi"><div className="label">Statut</div>
|
||||
<div style={{ fontSize: '1.2rem', fontWeight: 700, marginTop: 4 }}>{STATUT_LABELS[deal.statut] || deal.statut}</div></div>
|
||||
</div>
|
||||
<div className="text-muted" style={{ marginTop: 14, fontSize: 13 }}>
|
||||
{deal.plateforme_nom} — {deal.investisseur_nom} · Souscrit le {fmtDate(deal.date_souscription)}
|
||||
{deal.strategie ? ` · ${deal.strategie}` : ''}
|
||||
{deal.objectif ? ` · Objectif : ${deal.objectif}` : ''}
|
||||
{deal.reference ? ` · Réf. ${deal.reference}` : ''}
|
||||
</div>
|
||||
{deal.notes && <div className="text-muted" style={{ marginTop: 8, fontSize: 13 }}>{deal.notes}</div>}
|
||||
</div>
|
||||
|
||||
{/* ── Valorisations ── */}
|
||||
<div className="card" style={{ marginBottom: 16 }}>
|
||||
<h3 style={{ marginTop: 0, marginBottom: 14 }}>Valorisations</h3>
|
||||
{valErr && <div className="error">{valErr}</div>}
|
||||
<form onSubmit={e => runValSubmit(submitVal, e)} className="form-grid" style={{ marginBottom: 16 }}>
|
||||
<div>
|
||||
<label>Date *</label>
|
||||
<input type="date" required value={valForm.date_valorisation} onChange={e => setValForm({ ...valForm, date_valorisation: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<label>Valorisation nette (€) *</label>
|
||||
<input type="number" step="0.01" min="0" required value={valForm.valorisation_nette} onChange={e => setValForm({ ...valForm, valorisation_nette: e.target.value })} />
|
||||
</div>
|
||||
<div style={{ gridColumn: '1 / -1' }}>
|
||||
<label>Notes</label>
|
||||
<input value={valForm.notes} onChange={e => setValForm({ ...valForm, notes: e.target.value })} />
|
||||
</div>
|
||||
<div style={{ gridColumn: '1 / -1' }}>
|
||||
<button type="submit" className="primary" disabled={valSubmitting}>
|
||||
{valSubmitting ? 'Ajout…' : '+ Ajouter une valorisation'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<table>
|
||||
<thead><tr><th>Date</th><th className="num">Valorisation nette</th><th>Notes</th><th style={{ width: 40 }}></th></tr></thead>
|
||||
<tbody>
|
||||
{valorisations.length === 0 && (
|
||||
<tr><td colSpan={4} className="text-muted" style={{ textAlign: 'center' }}>Aucune valorisation enregistrée</td></tr>
|
||||
)}
|
||||
{valorisations.map(v => (
|
||||
<tr key={v.id}>
|
||||
<td>{fmtDate(v.date_valorisation)}</td>
|
||||
<td className="num">{fmtEUR(v.valorisation_nette)}</td>
|
||||
<td className="text-muted">{v.notes || '—'}</td>
|
||||
<td><button className="icon-btn" title="Supprimer" onClick={() => setValDeleteTarget(v)}>✕</button></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* ── Remboursements ── */}
|
||||
<div className="card" style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
|
||||
<h3 style={{ margin: 0 }}>Remboursements</h3>
|
||||
<button onClick={() => navigate('/remboursements?new=1')}>+ Nouveau remboursement</button>
|
||||
</div>
|
||||
<table>
|
||||
<thead><tr><th>Date</th><th className="num">Montant versé</th><th>Notes</th></tr></thead>
|
||||
<tbody>
|
||||
{remboursements.length === 0 && (
|
||||
<tr><td colSpan={3} className="text-muted" style={{ textAlign: 'center' }}>Aucun remboursement enregistré pour ce deal</td></tr>
|
||||
)}
|
||||
{remboursements.map(r => (
|
||||
<tr key={r.id} style={{ cursor: 'pointer' }} onClick={() => navigate('/remboursements')} title="Voir dans Remboursements">
|
||||
<td>{fmtDate(r.date_remb)}</td>
|
||||
<td className="num">{fmtEUR(r.net_recu)}</td>
|
||||
<td className="text-muted">{r.notes || '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* ── Frais d'opération ── */}
|
||||
<div className="card" style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
|
||||
<h3 style={{ margin: 0 }}>
|
||||
Frais d'opération
|
||||
{fraisOps.length > 0 && <span className="text-muted" style={{ fontWeight: 400, fontSize: 13, marginLeft: 8 }}>Total : {fmtEUR(totalFrais)}</span>}
|
||||
</h3>
|
||||
<button onClick={openAddFrais}>+ Ajouter un frais</button>
|
||||
</div>
|
||||
{fraisOps.length === 0 ? (
|
||||
<div className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>Aucun frais journalisé pour l'instant.</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{fraisOps.map(f => {
|
||||
const isManaged = f.categorie === 'souscription' || (f.categorie === 'gestion' && f.origine === 'souscription');
|
||||
return (
|
||||
<div key={f.id} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '8px 10px', borderRadius: 8, border: '1px solid var(--border)' }}>
|
||||
<div style={{ flex: 1, minWidth: 0, cursor: isManaged ? 'pointer' : 'pointer' }}
|
||||
onClick={() => isManaged ? openEditDeal() : openEditFrais(f)}
|
||||
title={isManaged ? "Modifiable depuis « Modifier l'investissement »" : 'Cliquer pour modifier'}>
|
||||
<div style={{ fontWeight: 500, fontSize: 'var(--fs-sm)' }}>
|
||||
{fmtEUR(f.montant)}
|
||||
<span className="text-muted" style={{ fontWeight: 400, marginLeft: 8 }}>
|
||||
{FRAIS_MODE_LABELS[f.mode_reglement] || f.mode_reglement}
|
||||
{f.mode_reglement === 'compte_courant' && f.compte_nom ? ` (${f.compte_nom})` : ''}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
{fmtDate(f.date_operation)} · {FRAIS_CATEGORIE_LABELS[f.categorie] || f.categorie}
|
||||
{f.notes ? ` · ${f.notes}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
{!isManaged && (
|
||||
<button className="icon-btn" title="Supprimer" onClick={() => { setFraisEditingId(f.id); onDeleteFrais(); }}>✕</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Documents ── */}
|
||||
<div className="card" style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
|
||||
<h3 style={{ margin: 0 }}>Mes documents</h3>
|
||||
<button onClick={() => docFileInputRef.current?.click()} disabled={uploadingDoc}>
|
||||
{uploadingDoc ? 'Envoi…' : '+ Ajouter un document'}
|
||||
</button>
|
||||
</div>
|
||||
{docsErr && <div className="error">{docsErr}</div>}
|
||||
{documents.length === 0 ? (
|
||||
<div className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>Aucun document pour l'instant.</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{documents.map(doc => (
|
||||
<div key={doc.id} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '8px 10px', borderRadius: 8, border: '1px solid var(--border)' }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
{renamingDocId === doc.id ? (
|
||||
<input autoFocus value={renameValue} onChange={e => setRenameValue(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleDocRenameCommit(doc.id); if (e.key === 'Escape') setRenamingDocId(null); }}
|
||||
onBlur={() => handleDocRenameCommit(doc.id)} style={{ width: '100%', fontSize: 'var(--fs-sm)' }} />
|
||||
) : (
|
||||
<div onClick={() => handleDocRenameStart(doc)} title="Cliquer pour renommer"
|
||||
style={{ fontWeight: 500, fontSize: 'var(--fs-sm)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', cursor: 'pointer' }}>
|
||||
{doc.nom_affichage}.{doc.extension}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{fmtOctets(doc.taille_octets)} · {fmtDate(doc.created_at)}</div>
|
||||
</div>
|
||||
<button className="icon-btn" title="Télécharger" onClick={() => handleDocDownload(doc)}>⬇</button>
|
||||
<button className="icon-btn" title="Supprimer" onClick={() => setDocDeleteTarget(doc)}>✕</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<input ref={docFileInputRef} type="file" onChange={handleDocFileChange} style={{ display: 'none' }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<InvestissementPeFormModal
|
||||
open={modalOpen}
|
||||
editingId={id}
|
||||
onClose={closeEditDeal}
|
||||
form={form}
|
||||
setForm={setForm}
|
||||
plats={plats}
|
||||
investisseurs={investisseurs}
|
||||
multiDetenteur={new Set(plats.map(p => p.investisseur_id)).size > 1}
|
||||
comptesInvestisseur={comptesInvestisseur}
|
||||
err={formErr}
|
||||
submitting={submitting}
|
||||
onSubmit={() => runSubmit(submitDeal)}
|
||||
onDelete={() => { closeEditDeal(); setDeleteConfirm(true); }}
|
||||
investisseurForPlat={investisseurForPlat}
|
||||
/>
|
||||
|
||||
<ConfirmModal
|
||||
open={deleteConfirm}
|
||||
title="Supprimer cet investissement ?"
|
||||
message="Cette action est irréversible. Les distributions déjà saisies (Remboursements) sont conservées mais détachées de ce deal."
|
||||
confirmLabel="Supprimer"
|
||||
onConfirm={onDeleteDeal}
|
||||
onCancel={() => setDeleteConfirm(false)}
|
||||
/>
|
||||
<ConfirmModal
|
||||
open={!!valDeleteTarget}
|
||||
title="Supprimer cette valorisation ?"
|
||||
message="Cette action est irréversible."
|
||||
confirmLabel="Supprimer"
|
||||
onConfirm={onDeleteVal}
|
||||
onCancel={() => setValDeleteTarget(null)}
|
||||
/>
|
||||
<ConfirmModal
|
||||
open={!!docDeleteTarget}
|
||||
title="Supprimer ce document ?"
|
||||
message={docDeleteTarget ? `"${docDeleteTarget.nom_affichage}.${docDeleteTarget.extension}" sera définitivement supprimé.` : ''}
|
||||
confirmLabel="Supprimer"
|
||||
onConfirm={handleDocDelete}
|
||||
onCancel={() => setDocDeleteTarget(null)}
|
||||
/>
|
||||
|
||||
<FraisFormModal
|
||||
open={fraisModalOpen}
|
||||
onClose={closeFraisModal}
|
||||
editingId={fraisEditingId}
|
||||
form={fraisForm}
|
||||
onFieldChange={(k, v) => setFraisForm(f => ({ ...f, [k]: v }))}
|
||||
err={fraisErr}
|
||||
onSubmit={e => runFraisSubmit(submitFrais, e)}
|
||||
onDelete={() => runFraisSubmit(onDeleteFrais)}
|
||||
submitting={fraisSubmitting}
|
||||
comptesInvestisseur={comptesInvestisseur}
|
||||
locked
|
||||
investissementOptions={[]}
|
||||
confirmingDelete={fraisConfirmingDelete}
|
||||
setConfirmingDelete={setFraisConfirmingDelete}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { usePagination } from '../hooks/usePagination.js';
|
||||
import { useAsyncAction } from '../hooks/useAsyncAction.js';
|
||||
import Pagination from '../components/Pagination.jsx';
|
||||
import PageIcon from '../components/PageIcon.jsx';
|
||||
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
@@ -296,6 +297,7 @@ export default function Investissements() {
|
||||
const [form, setForm] = useState(empty);
|
||||
const [err, setErr] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { run: runSubmit, pending: submitting } = useAsyncAction();
|
||||
const [deleteConfirm, setDeleteConfirm] = useState(null);
|
||||
const [openMenu, setOpenMenu] = useState(null);
|
||||
// Proposition de dépôt après création d'un investissement si le porte-monnaie ne
|
||||
@@ -1437,7 +1439,8 @@ export default function Investissements() {
|
||||
open={modalOpen}
|
||||
editingId={editingId}
|
||||
onClose={close}
|
||||
onSubmit={submit}
|
||||
onSubmit={e => runSubmit(submit, e)}
|
||||
submitting={submitting}
|
||||
form={form}
|
||||
onFieldChange={(key, value) => setForm(f => ({ ...f, [key]: value }))}
|
||||
onFormPatch={patch => setForm(f => typeof patch === 'function' ? patch(f) : { ...f, ...patch })}
|
||||
|
||||
@@ -0,0 +1,938 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useLocation, useNavigate } 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 ValorisationPeChart from '../components/ValorisationPeChart.jsx';
|
||||
import DistributionChart from '../components/DistributionChart.jsx';
|
||||
import ValorisationMensuelleTable from '../components/ValorisationMensuelleTable.jsx';
|
||||
import InvestissementPeFormModal from '../components/InvestissementPeFormModal.jsx';
|
||||
import FraisFormModal from '../components/FraisFormModal.jsx';
|
||||
import { useAsyncAction } from '../hooks/useAsyncAction.js';
|
||||
import { fmtEUR, fmtDate, today } from '../utils/format.js';
|
||||
|
||||
const STATUT_LABELS = {
|
||||
en_attente: 'En attente',
|
||||
valide: 'Validé',
|
||||
cloture: 'Clôturé',
|
||||
perte_definitive: 'Perte définitive',
|
||||
};
|
||||
|
||||
export const emptyDealForm = {
|
||||
plateforme_id: '',
|
||||
investisseur_id: '',
|
||||
nom_deal: '',
|
||||
strategie: '',
|
||||
date_souscription: today(),
|
||||
duree_annees: '',
|
||||
montant_investi: '',
|
||||
objectif: '',
|
||||
statut: 'valide',
|
||||
reference: '',
|
||||
notes: '',
|
||||
// É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
|
||||
// d'entrée → catégorie 'souscription' (unique par deal) ; Frais de gestion → catégorie
|
||||
// 'gestion', origine 'souscription' (distingue cette ligne d'un frais de gestion ajouté plus
|
||||
// tard depuis la page Frais PE ou la liste des investissements).
|
||||
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,
|
||||
// Id de la valorisation "initiale" auto-générée à la date de souscription pour refléter les
|
||||
// frais réglés "à la source" (cf. AUTO_VAL_NOTE / submit ci-dessous) — null si aucune n'existe
|
||||
// encore pour ce deal. Décision Olivier 17/09/26 : le montant investi (souscrit) ne bouge
|
||||
// JAMAIS, c'est la valorisation qui encaisse immédiatement les frais d'entrée/de gestion à la
|
||||
// date de souscription — jusqu'à ce qu'une vraie valorisation du fonds vienne la remplacer.
|
||||
valorisation_initiale_id: null,
|
||||
};
|
||||
|
||||
|
||||
// Marqueur de la valorisation "initiale" auto-générée par l'étape "Frais" de la modale (cf.
|
||||
// submit ci-dessous) — permet de la retrouver/remplacer sans toucher aux valorisations saisies
|
||||
// manuellement par ailleurs (modale "Valorisations" annexe).
|
||||
export const AUTO_VAL_NOTE = 'Valorisation initiale (frais de souscription)';
|
||||
|
||||
// Accès rapide "+ frais" depuis une ligne de la liste des investissements (chantier "frais PE",
|
||||
// 17/09/26, demande Olivier) — permet d'ajouter un frais complémentaire (gestion récurrente,
|
||||
// distribution, autre) sans passer par la page Frais PE. Catégorie par défaut 'gestion' :
|
||||
// 'souscription' reste exclusivement gérée par l'étape "Frais" de la modale d'investissement
|
||||
// (cf. FraisFormModal.jsx, qui exclut déjà cette catégorie de son sélecteur).
|
||||
const emptyFraisAddForm = { montant: '', mode_reglement: 'source', compte_id: '', date_operation: today(), categorie: 'gestion', notes: '' };
|
||||
|
||||
const fmtMultiple = (n) => (n != null ? `x${n.toFixed(2).replace('.', ',')}` : '—');
|
||||
|
||||
/* ── Indicateur de progression (copié de pages/Investissements.jsx —
|
||||
* pas exporté par un composant partagé) ── */
|
||||
function TrendBadge({ current, prev, invert = false }) {
|
||||
if (prev == null || Math.abs(prev) < 0.005) return null;
|
||||
const diff = current - prev;
|
||||
const pct = (diff / prev) * 100;
|
||||
const up = diff > 0;
|
||||
const neutral = diff === 0;
|
||||
const good = neutral ? null : (invert ? !up : up);
|
||||
const color = neutral ? 'var(--text-muted)' : good ? '#16a34a' : '#dc2626';
|
||||
const bg = neutral ? 'var(--surface-2)' : good ? 'rgba(34,197,94,0.12)' : 'rgba(239,68,68,0.12)';
|
||||
const arrow = neutral ? '→' : up ? '↗' : '↘';
|
||||
const label = `${up ? '+' : ''}${Math.abs(pct) < 10 ? pct.toFixed(1) : Math.round(pct)}%`;
|
||||
return (
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 3,
|
||||
padding: '2px 8px', borderRadius: 20,
|
||||
background: bg, color, fontSize: '0.76em', fontWeight: 600,
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{arrow} {label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Investissements — Private Equity (17/09/26, cf.
|
||||
* project_workspaces_transformation.md). Sert tout workspace autre que
|
||||
* crowdlending (cf. InvestissementsRouter dans App.jsx, jamais lié en dur à
|
||||
* un slug précis), sur une table dédiée investissements_pe : un deal PE n'a
|
||||
* ni échéancier connu à l'avance ni taux d'intérêt fixe — c'est un montant
|
||||
* unique investi (le porte-monnaie diminue d'un coup, comme en crowdlending,
|
||||
* décision Olivier), valorisé périodiquement par le fonds. Les distributions
|
||||
* (retour de capital, plus-value) se saisissent depuis la page Remboursements
|
||||
* (types bonus_plateforme / intérêts_plateforme), avec un lien optionnel vers
|
||||
* le deal pour calculer son TVPI.
|
||||
*
|
||||
* Depuis le 17/09/26 (demande d'Olivier : "modifier la page investissements
|
||||
* de PE pour qu'elle ressemble à celle de crowdlending"), cette page reprend
|
||||
* le graphique cumulé, la Distribution par plateforme, 5 KPIs et les onglets
|
||||
* Plateformes / Vision mensuelle / Investissements de pages/Investissements.jsx
|
||||
* (crowdlending) — MAIS avec une sémantique adaptée au modèle PE (décisions
|
||||
* Olivier du 17/09/26, cf. les 4 questions posées avant l'implémentation) :
|
||||
* - Le graphique et la 1ère carte KPI n'affichent pas un "capital en cours"
|
||||
* (qui décroît avec les remboursements, notion sans équivalent en PE
|
||||
* puisque montant_investi n'est jamais décrémenté) mais la VALORISATION
|
||||
* TOTALE du portefeuille : pour chaque deal, sa dernière valeur connue
|
||||
* (montant_investi à la souscription, puis chaque valorisation saisie la
|
||||
* remplace), sommée sur tous les deals — cf. ValorisationPeChart.jsx.
|
||||
* - La 2e carte "Investissements à risque" devient "Pertes définitives"
|
||||
* (somme du montant investi des deals au statut perte_definitive) — PE n'a
|
||||
* pas de notion de retard/défaut comparable au crowdlending.
|
||||
* - Les cartes 4 et 5 ("Investissements remboursés" / "Intérêts perçus" en
|
||||
* crowdlending, deux notions distinctes) deviennent "Distributions
|
||||
* cumulées" (retour de cash du fonds, capital et plus-value confondus —
|
||||
* pas de séparation en PE) et "TVPI moyen" (multiple pondéré du
|
||||
* portefeuille : (valorisation + distributions) / capital investi).
|
||||
* - L'onglet "Vision mensuelle" devient un tableau de VALORISATION mensuelle
|
||||
* par plateforme (ValorisationMensuelleTable.jsx), et non plus un capital
|
||||
* en cours basé sur des remboursements qui n'existent pas pour ces deals.
|
||||
*
|
||||
* Approximation assumée pour les comparaisons N/N-1 (TrendBadge) sur les
|
||||
* cartes Distributions et TVPI : distributions_cumulees (renvoyé par
|
||||
* GET /investissements-pe) est une valeur globale, non bornée à une date —
|
||||
* la comparaison restreint seulement l'ENSEMBLE des deals pris en compte
|
||||
* (souscrits avant la coupure), pas le montant des distributions elles-
|
||||
* mêmes à cette date. Seule la valorisation (dont l'historique daté est
|
||||
* connu via GET /investissements-pe/valorisations) est bornée exactement à
|
||||
* la coupure. Même principe d'approximation que "Investissements à risque"
|
||||
* côté crowdlending, qui utilise déjà le statut ACTUEL plutôt qu'un statut
|
||||
* historique à la date de coupure.
|
||||
*/
|
||||
export default function InvestissementsPe() {
|
||||
const { activeId: investisseurId, investisseurs } = useInvestisseur();
|
||||
const { active: activeWorkspace } = useWorkspace() || {};
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [rows, setRows] = useState([]);
|
||||
const [allPlats, setAllPlats] = useState([]);
|
||||
const [valorisations, setValorisations] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [err, setErr] = useState(null);
|
||||
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState(null);
|
||||
const [form, setForm] = useState(emptyDealForm);
|
||||
const [deleteTarget, setDeleteTarget] = useState(null);
|
||||
const { run: runSubmit, pending: submitting } = useAsyncAction();
|
||||
// Comptes du détenteur sélectionné dans le formulaire — nécessaires pour l'étape "Frais"
|
||||
// (option de règlement "compte courant du détenteur"), même mécanisme que Frais.jsx /
|
||||
// Investissements.jsx côté crowdlending (route déjà générique, indépendante du workspace).
|
||||
const [comptesInvestisseur, setComptesInvestisseur] = useState([]);
|
||||
|
||||
// Modale annexe "+ frais" (accès rapide depuis une ligne de la liste des investissements)
|
||||
const [fraisAddDeal, setFraisAddDeal] = useState(null);
|
||||
const [fraisAddForm, setFraisAddForm] = useState(emptyFraisAddForm);
|
||||
const [fraisAddErr, setFraisAddErr] = useState(null);
|
||||
const [fraisAddComptes, setFraisAddComptes] = useState([]);
|
||||
const { run: runFraisAddSubmit, pending: fraisAddSubmitting } = useAsyncAction();
|
||||
|
||||
const [activeTab, setActiveTab] = useState('plateformes');
|
||||
const [filter, setFilter] = useState({ statut: '', plateforme_id: '' });
|
||||
const [platYear, setPlatYear] = useState('');
|
||||
const [listFocused, setListFocused] = useState(false);
|
||||
|
||||
// Valorisations : petite modale annexe, ouverte depuis une ligne du tableau.
|
||||
|
||||
// Plateformes éligibles au workspace actif uniquement (GET /plateformes
|
||||
// n'est pas filtré côté serveur — seulement enrichi de row.workspaces),
|
||||
// même patron que DepotsRetraitsSimple / RemboursementsSimple.
|
||||
const plats = useMemo(
|
||||
() => allPlats.filter(p => (p.workspaces || []).some(w => w.id === activeWorkspace?.id)),
|
||||
[allPlats, activeWorkspace],
|
||||
);
|
||||
|
||||
const multiDetenteur = new Set(plats.map(p => p.investisseur_id)).size > 1;
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [inv, pl, val] = await Promise.all([
|
||||
api.get('/investissements-pe'),
|
||||
api.get('/plateformes'),
|
||||
api.get('/investissements-pe/valorisations'),
|
||||
]);
|
||||
setRows(inv);
|
||||
setAllPlats(pl);
|
||||
setValorisations(val);
|
||||
} catch (e) {
|
||||
setErr(e.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (investisseurId) load();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [investisseurId, activeWorkspace?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!form.investisseur_id || !modalOpen) { setComptesInvestisseur([]); return; }
|
||||
api.get(`/investissements/comptes-par-investisseur/${form.investisseur_id}`)
|
||||
.then(setComptesInvestisseur)
|
||||
.catch(() => setComptesInvestisseur([]));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [form.investisseur_id, modalOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!fraisAddDeal?.investisseur_id) { setFraisAddComptes([]); return; }
|
||||
api.get(`/investissements/comptes-par-investisseur/${fraisAddDeal.investisseur_id}`)
|
||||
.then(setFraisAddComptes)
|
||||
.catch(() => setFraisAddComptes([]));
|
||||
}, [fraisAddDeal?.investisseur_id]);
|
||||
|
||||
const openFraisAdd = (deal) => {
|
||||
setFraisAddDeal(deal);
|
||||
setFraisAddForm({ ...emptyFraisAddForm, date_operation: today() });
|
||||
setFraisAddErr(null);
|
||||
};
|
||||
const closeFraisAdd = () => { setFraisAddDeal(null); setFraisAddErr(null); };
|
||||
const submitFraisAdd = async (e) => {
|
||||
e?.preventDefault?.(); setFraisAddErr(null);
|
||||
try {
|
||||
await api.post('/frais-operations', {
|
||||
investissement_pe_id: fraisAddDeal.id,
|
||||
montant: Number(fraisAddForm.montant),
|
||||
mode_reglement: fraisAddForm.mode_reglement,
|
||||
compte_id: fraisAddForm.mode_reglement === 'compte_courant' && fraisAddForm.compte_id ? Number(fraisAddForm.compte_id) : null,
|
||||
date_operation: fraisAddForm.date_operation,
|
||||
origine: 'manuel',
|
||||
categorie: fraisAddForm.categorie,
|
||||
notes: fraisAddForm.notes || undefined,
|
||||
});
|
||||
closeFraisAdd();
|
||||
} catch (e) { setFraisAddErr(e.message); }
|
||||
};
|
||||
|
||||
const investisseurForPlat = (platId) => {
|
||||
const plat = plats.find(p => String(p.id) === String(platId));
|
||||
return plat?.investisseur_id ? String(plat.investisseur_id) : String(investisseurId || '');
|
||||
};
|
||||
|
||||
const openAdd = () => {
|
||||
setEditingId(null);
|
||||
const firstId = plats[0]?.id || '';
|
||||
setForm({ ...emptyDealForm, plateforme_id: firstId, investisseur_id: investisseurForPlat(firstId) });
|
||||
setErr(null);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
// Ouverture auto depuis le bouton global "Ajout Investissement" (topbar/burger,
|
||||
// cf. Layout.jsx — même ?new=1 que les pages Dépôts/Retraits et Remboursements).
|
||||
const pendingNew = useRef(false);
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
if (params.get('new') !== '1') return;
|
||||
navigate('/investissements', { replace: true });
|
||||
if (plats.length > 0) {
|
||||
openAdd();
|
||||
} else {
|
||||
pendingNew.current = true;
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [location.search]);
|
||||
|
||||
useEffect(() => {
|
||||
if (pendingNew.current && plats.length > 0) {
|
||||
pendingNew.current = false;
|
||||
openAdd();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [plats.length]);
|
||||
|
||||
const openEdit = async (r) => {
|
||||
setEditingId(r.id);
|
||||
setForm({
|
||||
plateforme_id: String(r.plateforme_id || ''),
|
||||
investisseur_id: String(r.investisseur_id || ''),
|
||||
nom_deal: r.nom_deal,
|
||||
strategie: r.strategie || '',
|
||||
date_souscription: r.date_souscription,
|
||||
duree_annees: r.duree_mois ? String(Math.round((r.duree_mois / 12) * 10) / 10) : '',
|
||||
montant_investi: String(r.montant_investi ?? ''),
|
||||
objectif: r.objectif || '',
|
||||
statut: r.statut,
|
||||
reference: r.reference || '',
|
||||
notes: r.notes || '',
|
||||
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,
|
||||
});
|
||||
setErr(null);
|
||||
setModalOpen(true);
|
||||
|
||||
// Pré-remplissage best-effort des frais déjà journalisés + de la valorisation "initiale"
|
||||
// auto-générée (cf. AUTO_VAL_NOTE/submit) pour ce deal — n'empêche jamais l'ouverture de la
|
||||
// modale si l'appel échoue.
|
||||
try {
|
||||
const [fraisOps, valRows] = await Promise.all([
|
||||
api.get('/frais-operations', { investissement_pe_id: r.id }),
|
||||
api.get(`/investissements-pe/${r.id}/valorisations`),
|
||||
]);
|
||||
const entree = fraisOps.find(f => f.categorie === 'souscription');
|
||||
const gestion = fraisOps.find(f => f.categorie === 'gestion' && f.origine === 'souscription');
|
||||
const autoVal = valRows.find(v => v.notes === AUTO_VAL_NOTE);
|
||||
setForm(f => ({
|
||||
...f,
|
||||
valorisation_initiale_id: autoVal ? autoVal.id : null,
|
||||
...(entree ? {
|
||||
frais_entree_valeur: String(entree.montant), frais_entree_mode_reglement: entree.mode_reglement,
|
||||
frais_entree_compte_id: entree.compte_id ? String(entree.compte_id) : '', frais_entree_id: entree.id,
|
||||
} : {}),
|
||||
...(gestion ? {
|
||||
frais_gestion_valeur: String(gestion.montant), frais_gestion_mode_reglement: gestion.mode_reglement,
|
||||
frais_gestion_compte_id: gestion.compte_id ? String(gestion.compte_id) : '', frais_gestion_id: gestion.id,
|
||||
} : {}),
|
||||
}));
|
||||
} catch { /* non bloquant — rattrapable depuis la page Frais PE / la modale Valorisations */ }
|
||||
};
|
||||
|
||||
const close = () => { setModalOpen(false); setEditingId(null); setForm(emptyDealForm); setErr(null); };
|
||||
|
||||
// Calcule le TTC d'une ligne de frais depuis son mode de saisie (montant fixe ou % du
|
||||
// montant investi) — même formule que Investissements.jsx (crowdlending).
|
||||
const computeFraisTTC = (modeSaisie, valeur) => {
|
||||
if (valeur === '' || Number(valeur) <= 0) return 0;
|
||||
return Math.round((modeSaisie === 'pourcentage'
|
||||
? Number(form.montant_investi) * Number(valeur) / 100
|
||||
: Number(valeur)) * 100) / 100;
|
||||
};
|
||||
|
||||
// Upsert best-effort d'une ligne frais_operations (PUT si déjà chargée via *_id, POST sinon ;
|
||||
// DELETE si le montant a été vidé alors qu'une entrée existait) — n'empêche jamais
|
||||
// l'enregistrement principal du deal, rattrapable a posteriori depuis la page Frais PE.
|
||||
const upsertFrais = async (dealId, has, ttc, categorie, modeReglement, compteId, fraisId) => {
|
||||
try {
|
||||
if (has) {
|
||||
const fraisPayload = {
|
||||
investissement_pe_id: dealId,
|
||||
montant: ttc,
|
||||
mode_reglement: modeReglement,
|
||||
compte_id: modeReglement === 'compte_courant' && compteId ? Number(compteId) : null,
|
||||
date_operation: form.date_souscription,
|
||||
origine: 'souscription',
|
||||
categorie,
|
||||
};
|
||||
if (fraisId) await api.put(`/frais-operations/${fraisId}`, fraisPayload);
|
||||
else await api.post('/frais-operations', fraisPayload);
|
||||
} else if (fraisId) {
|
||||
await api.del(`/frais-operations/${fraisId}`);
|
||||
}
|
||||
} catch { /* non bloquant */ }
|
||||
};
|
||||
|
||||
// Gère la valorisation "initiale" auto-générée à la date de souscription (cf. AUTO_VAL_NOTE) :
|
||||
// le montant investi (souscrit) ne bouge jamais, c'est cette valorisation qui encaisse
|
||||
// immédiatement les frais réglés "à la source" — décision Olivier 17/09/26. Comme l'API ne
|
||||
// propose pas de PUT sur une valorisation, on supprime l'éventuelle ligne existante puis on
|
||||
// recrée à neuf (couvre aussi le cas où la date de souscription a changé). Best-effort, jamais
|
||||
// bloquant pour l'enregistrement principal du deal.
|
||||
const syncValorisationInitiale = async (dealId, totalFraisSource, valorisationInitialeId) => {
|
||||
try {
|
||||
if (valorisationInitialeId) {
|
||||
await api.del(`/investissements-pe/${dealId}/valorisations/${valorisationInitialeId}`);
|
||||
}
|
||||
if (totalFraisSource > 0) {
|
||||
const initiale = Math.max(0, Math.round((Number(form.montant_investi) - totalFraisSource) * 100) / 100);
|
||||
await api.post(`/investissements-pe/${dealId}/valorisations`, {
|
||||
date_valorisation: form.date_souscription,
|
||||
valorisation_nette: initiale,
|
||||
notes: AUTO_VAL_NOTE,
|
||||
});
|
||||
}
|
||||
} catch { /* non bloquant — rattrapable depuis la modale Valorisations */ }
|
||||
};
|
||||
|
||||
const submit = async (e) => {
|
||||
e?.preventDefault?.(); setErr(null);
|
||||
|
||||
const platFraisType = plats.find(p => String(p.id) === String(form.plateforme_id))?.frais_plateforme;
|
||||
const fraisApplicable = !!platFraisType && platFraisType !== 'aucun';
|
||||
|
||||
const hasFraisEntree = fraisApplicable && form.frais_entree_valeur !== '' && Number(form.frais_entree_valeur) > 0;
|
||||
const hasFraisGestion = fraisApplicable && form.frais_gestion_valeur !== '' && Number(form.frais_gestion_valeur) > 0;
|
||||
const fraisEntreeTTC = hasFraisEntree ? computeFraisTTC(form.frais_entree_mode_saisie, form.frais_entree_valeur) : 0;
|
||||
const fraisGestionTTC = hasFraisGestion ? computeFraisTTC(form.frais_gestion_mode_saisie, form.frais_gestion_valeur) : 0;
|
||||
|
||||
// Total des frais réglés "à la source" (aucun mouvement de trésorerie réel) — c'est ce
|
||||
// montant qui vient réduire la VALORISATION à la date de souscription (cf.
|
||||
// syncValorisationInitiale), jamais le montant investi lui-même (décision Olivier 17/09/26).
|
||||
const totalFraisSource =
|
||||
(hasFraisEntree && form.frais_entree_mode_reglement === 'source' ? fraisEntreeTTC : 0) +
|
||||
(hasFraisGestion && form.frais_gestion_mode_reglement === 'source' ? fraisGestionTTC : 0);
|
||||
|
||||
const payload = {
|
||||
plateforme_id: Number(form.plateforme_id),
|
||||
investisseur_id: Number(form.investisseur_id) || undefined,
|
||||
nom_deal: form.nom_deal,
|
||||
strategie: form.strategie || undefined,
|
||||
date_souscription: form.date_souscription,
|
||||
duree_mois: form.duree_annees ? Math.round(Number(form.duree_annees) * 12) : undefined,
|
||||
montant_investi: Number(form.montant_investi),
|
||||
objectif: form.objectif || undefined,
|
||||
statut: form.statut,
|
||||
reference: form.reference || undefined,
|
||||
notes: form.notes || undefined,
|
||||
};
|
||||
try {
|
||||
let dealId = editingId;
|
||||
if (editingId) {
|
||||
await api.put(`/investissements-pe/${editingId}`, payload);
|
||||
} else {
|
||||
const created = await api.post('/investissements-pe', payload);
|
||||
dealId = created.id;
|
||||
}
|
||||
await upsertFrais(dealId, hasFraisEntree, fraisEntreeTTC, 'souscription', form.frais_entree_mode_reglement, form.frais_entree_compte_id, form.frais_entree_id);
|
||||
await upsertFrais(dealId, hasFraisGestion, fraisGestionTTC, 'gestion', form.frais_gestion_mode_reglement, form.frais_gestion_compte_id, form.frais_gestion_id);
|
||||
await syncValorisationInitiale(dealId, totalFraisSource, form.valorisation_initiale_id);
|
||||
close();
|
||||
await load();
|
||||
} catch (e) { setErr(e.message); }
|
||||
};
|
||||
|
||||
const onDelete = async () => {
|
||||
if (!deleteTarget) return;
|
||||
try {
|
||||
await api.del(`/investissements-pe/${deleteTarget.id}`);
|
||||
setDeleteTarget(null);
|
||||
await load();
|
||||
} catch (e) { setErr(e.message); }
|
||||
};
|
||||
|
||||
const sorted = useMemo(
|
||||
() => [...rows].sort((a, b) => b.date_souscription.localeCompare(a.date_souscription) || b.id - a.id),
|
||||
[rows],
|
||||
);
|
||||
|
||||
/* ── Années disponibles (sélecteur Plateformes) ── */
|
||||
const years = useMemo(
|
||||
() => [...new Set(rows.map(r => r.date_souscription?.slice(0, 4)).filter(Boolean))].sort().reverse(),
|
||||
[rows],
|
||||
);
|
||||
|
||||
/* ── Historique de valorisations trié, par deal (pour bornage à une
|
||||
* coupure — TrendBadge N/N-1 et graphique) ── */
|
||||
const valorisationsByDeal = useMemo(() => {
|
||||
const map = {};
|
||||
for (const v of valorisations) {
|
||||
const d = v.date_valorisation?.slice(0, 10);
|
||||
if (!d || v.valorisation_nette == null) continue;
|
||||
if (!map[v.investissement_pe_id]) map[v.investissement_pe_id] = [];
|
||||
map[v.investissement_pe_id].push({ date: d, value: v.valorisation_nette });
|
||||
}
|
||||
for (const k in map) map[k].sort((a, b) => a.date.localeCompare(b.date));
|
||||
return map;
|
||||
}, [valorisations]);
|
||||
|
||||
/* Valorisation d'un deal à une coupure donnée (ou dernière connue si
|
||||
* cutoff est null) — jamais < montant_investi tant qu'aucune valorisation
|
||||
* n'a été saisie. */
|
||||
const valorisationAtCutoff = (deal, cutoff) => {
|
||||
if (!cutoff) return deal.derniere_valorisation ?? deal.montant_investi;
|
||||
let val = deal.montant_investi;
|
||||
for (const v of (valorisationsByDeal[deal.id] || [])) {
|
||||
if (v.date <= cutoff) val = v.value; else break;
|
||||
}
|
||||
return val;
|
||||
};
|
||||
|
||||
/* ── Totaux KPI (plateforme + année sélectionnées) ── */
|
||||
const kpiRows = useMemo(() => rows.filter(r => {
|
||||
if (filter.plateforme_id && String(r.plateforme_id) !== String(filter.plateforme_id)) return false;
|
||||
if (platYear && r.date_souscription > `${platYear}-12-31`) return false;
|
||||
return true;
|
||||
}), [rows, filter.plateforme_id, platYear]);
|
||||
|
||||
const cutoff = platYear ? `${platYear}-12-31` : null;
|
||||
|
||||
const totals = useMemo(() => kpiRows.reduce((acc, r) => {
|
||||
acc.investi += r.montant_investi;
|
||||
acc.valorisation += valorisationAtCutoff(r, cutoff);
|
||||
acc.distributions += r.distributions_cumulees || 0;
|
||||
if (r.statut === 'perte_definitive') acc.pertes += r.montant_investi;
|
||||
return acc;
|
||||
}, { investi: 0, valorisation: 0, distributions: 0, pertes: 0 }),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[kpiRows, cutoff, valorisationsByDeal]);
|
||||
totals.tvpi = totals.investi > 0 ? (totals.valorisation + totals.distributions) / totals.investi : null;
|
||||
|
||||
const prevYear = platYear ? String(Number(platYear) - 1) : null;
|
||||
const prevCutoff = prevYear ? `${prevYear}-12-31` : null;
|
||||
|
||||
const prevRows = useMemo(() => {
|
||||
if (!platYear) return [];
|
||||
return rows.filter(r => {
|
||||
if (filter.plateforme_id && String(r.plateforme_id) !== String(filter.plateforme_id)) return false;
|
||||
return r.date_souscription <= prevCutoff;
|
||||
});
|
||||
}, [rows, filter.plateforme_id, platYear, prevCutoff]);
|
||||
|
||||
const prevTotals = useMemo(() => prevRows.reduce((acc, r) => {
|
||||
acc.investi += r.montant_investi;
|
||||
acc.valorisation += valorisationAtCutoff(r, prevCutoff);
|
||||
acc.distributions += r.distributions_cumulees || 0;
|
||||
if (r.statut === 'perte_definitive') acc.pertes += r.montant_investi;
|
||||
return acc;
|
||||
}, { investi: 0, valorisation: 0, distributions: 0, pertes: 0 }),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[prevRows, prevCutoff, valorisationsByDeal]);
|
||||
prevTotals.tvpi = prevTotals.investi > 0 ? (prevTotals.valorisation + prevTotals.distributions) / prevTotals.investi : null;
|
||||
|
||||
/* ── Données graphique / Distribution : filtrées par plateforme + année,
|
||||
* sans le filtre statut (comme Investissements.jsx côté crowdlending) ── */
|
||||
const chartRows = useMemo(() => rows.filter(r => {
|
||||
if (filter.plateforme_id && String(r.plateforme_id) !== String(filter.plateforme_id)) return false;
|
||||
if (platYear && r.date_souscription > `${platYear}-12-31`) return false;
|
||||
return true;
|
||||
}), [rows, filter.plateforme_id, platYear]);
|
||||
|
||||
const distRows = useMemo(() => chartRows
|
||||
.map(r => ({ plateforme_nom: r.plateforme_nom, montant: valorisationAtCutoff(r, cutoff) }))
|
||||
.filter(r => r.montant > 0),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[chartRows, cutoff, valorisationsByDeal]);
|
||||
|
||||
/* ── Onglet Plateformes : indépendant du filtre plateforme (comme
|
||||
* Investissements.jsx), seulement borné par l'année sélectionnée ── */
|
||||
const yearMembers = useMemo(() => rows.filter(r => !platYear || r.date_souscription <= `${platYear}-12-31`), [rows, platYear]);
|
||||
|
||||
const platData = useMemo(() => {
|
||||
const totalInvesti = yearMembers.reduce((s, r) => s + r.montant_investi, 0);
|
||||
const byPlat = {};
|
||||
for (const r of yearMembers) {
|
||||
if (!byPlat[r.plateforme_id]) {
|
||||
byPlat[r.plateforme_id] = {
|
||||
plateforme_id: r.plateforme_id, nom: r.plateforme_nom,
|
||||
detenteur_nom: r.investisseur_nom || null,
|
||||
count: 0, investi: 0, valorisation: 0, distributions: 0, pertes: 0,
|
||||
};
|
||||
}
|
||||
const p = byPlat[r.plateforme_id];
|
||||
p.count++;
|
||||
p.investi += r.montant_investi;
|
||||
p.valorisation += valorisationAtCutoff(r, cutoff);
|
||||
p.distributions += r.distributions_cumulees || 0;
|
||||
if (r.statut === 'perte_definitive') p.pertes += r.montant_investi;
|
||||
}
|
||||
return Object.values(byPlat)
|
||||
.sort((a, b) => b.investi - a.investi)
|
||||
.map(p => ({
|
||||
...p,
|
||||
tvpi: p.investi > 0 ? (p.valorisation + p.distributions) / p.investi : null,
|
||||
poids: totalInvesti > 0 ? (p.investi / totalInvesti) * 100 : 0,
|
||||
}));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [yearMembers, cutoff, valorisationsByDeal]);
|
||||
|
||||
const platTotals = useMemo(() => platData.reduce((acc, p) => {
|
||||
acc.count += p.count; acc.investi += p.investi;
|
||||
acc.valorisation += p.valorisation; acc.distributions += p.distributions;
|
||||
return acc;
|
||||
}, { count: 0, investi: 0, valorisation: 0, distributions: 0 }), [platData]);
|
||||
platTotals.tvpi = platTotals.investi > 0 ? (platTotals.valorisation + platTotals.distributions) / platTotals.investi : null;
|
||||
|
||||
/* ── Onglet Investissements : liste des deals, filtrée plateforme + statut ── */
|
||||
const filteredRows = useMemo(() => sorted.filter(r => {
|
||||
if (filter.plateforme_id && String(r.plateforme_id) !== String(filter.plateforme_id)) return false;
|
||||
if (filter.statut && r.statut !== filter.statut) return false;
|
||||
return true;
|
||||
}), [sorted, filter]);
|
||||
|
||||
const hasFilter = !!(filter.statut || filter.plateforme_id);
|
||||
const clearFilter = () => setFilter({ statut: '', plateforme_id: '' });
|
||||
|
||||
if (!loading && plats.length === 0) return (
|
||||
<>
|
||||
<div className="topbar"><h2><PageIcon name="investissement" />Investissements</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 pour pouvoir y enregistrer des investissements.`}
|
||||
cta="Configurer mes plateformes"
|
||||
to="/settings?section=plateformes&openAdd=1"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
if (!loading && !modalOpen && sorted.length === 0) return (
|
||||
<>
|
||||
<div className="topbar"><h2><PageIcon name="investissement" />Investissements</h2></div>
|
||||
<EmptyState
|
||||
icon="📈"
|
||||
title="Aucun investissement enregistré"
|
||||
message="Vos plateformes sont configurées, mais aucun investissement n'a encore été enregistré."
|
||||
cta="Ajouter un investissement"
|
||||
to="/investissements?new=1"
|
||||
/>
|
||||
<InvestissementPeFormModal
|
||||
open={modalOpen}
|
||||
editingId={editingId}
|
||||
onClose={close}
|
||||
form={form}
|
||||
setForm={setForm}
|
||||
plats={plats}
|
||||
investisseurs={investisseurs}
|
||||
multiDetenteur={multiDetenteur}
|
||||
comptesInvestisseur={comptesInvestisseur}
|
||||
err={err}
|
||||
submitting={submitting}
|
||||
onSubmit={() => runSubmit(submit)}
|
||||
onDelete={() => { const target = { id: editingId }; close(); setDeleteTarget(target); }}
|
||||
investisseurForPlat={investisseurForPlat}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="topbar">
|
||||
<h2><PageIcon name="investissement" />Investissements</h2>
|
||||
{hasFilter && (
|
||||
<button className="dr-clear-btn" onClick={clearFilter}>✕ Effacer les filtres</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{err && <div className="error" style={{ margin: '0 0 16px' }}>{err}</div>}
|
||||
|
||||
{/* ── Graphiques ── */}
|
||||
{!listFocused && <div className="charts-row">
|
||||
<ValorisationPeChart rows={chartRows} valorisations={valorisations} platYear={platYear} />
|
||||
<DistributionChart rows={distRows} />
|
||||
</div>}
|
||||
|
||||
{/* ── KPIs ── */}
|
||||
{!listFocused && <div className="dr-kpi-row" style={{ gridTemplateColumns: 'repeat(5, 1fr)' }}>
|
||||
|
||||
{/* 1 — Valorisation totale */}
|
||||
<div className="kpi">
|
||||
<div className="label">Valorisation totale</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
|
||||
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fmtEUR(totals.valorisation)}</span>
|
||||
{platYear && <TrendBadge current={totals.valorisation} prev={prevTotals.valorisation} />}
|
||||
</div>
|
||||
{platYear && <div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{fmtEUR(prevTotals.valorisation)} en {prevYear}</div>}
|
||||
</div>
|
||||
|
||||
{/* 2 — Pertes définitives */}
|
||||
<div
|
||||
className={`kpi${totals.pertes > 0 ? ' dr-kpi-clickable' : ''}${filter.statut === 'perte_definitive' ? ' dr-kpi-active dr-kpi-active-danger' : ''}`}
|
||||
onClick={() => {
|
||||
if (totals.pertes === 0) return;
|
||||
setFilter(f => ({ ...f, statut: f.statut === 'perte_definitive' ? '' : 'perte_definitive' }));
|
||||
setActiveTab('investissements');
|
||||
}}
|
||||
title={totals.pertes > 0 ? 'Filtrer les deals en perte définitive' : undefined}
|
||||
style={totals.pertes > 0 ? { cursor: 'pointer' } : {}}
|
||||
>
|
||||
<div className="label">Pertes définitives</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
|
||||
<span style={{ fontSize: '1.35rem', fontWeight: 700 }} className={totals.pertes > 0 ? 'danger' : ''}>{fmtEUR(totals.pertes)}</span>
|
||||
{platYear && totals.pertes > 0 && <TrendBadge current={totals.pertes} prev={prevTotals.pertes} invert={true} />}
|
||||
</div>
|
||||
{platYear && <div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{fmtEUR(prevTotals.pertes)} en {prevYear}</div>}
|
||||
</div>
|
||||
|
||||
{/* 3 — Investissements depuis le début */}
|
||||
<div
|
||||
className={`kpi${hasFilter ? ' dr-kpi-clickable dr-kpi-active dr-kpi-active-neutral' : ''}`}
|
||||
onClick={() => hasFilter && clearFilter()}
|
||||
title={hasFilter ? 'Effacer les filtres' : undefined}
|
||||
style={hasFilter ? { cursor: 'pointer' } : {}}
|
||||
>
|
||||
<div className="label">Investissements depuis le début</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
|
||||
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fmtEUR(totals.investi)}</span>
|
||||
{platYear && <TrendBadge current={totals.investi} prev={prevTotals.investi} />}
|
||||
</div>
|
||||
{platYear && <div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{fmtEUR(prevTotals.investi)} en {prevYear}</div>}
|
||||
</div>
|
||||
|
||||
{/* 4 — Distributions cumulées */}
|
||||
<div className="kpi">
|
||||
<div className="label">Distributions cumulées</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
|
||||
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fmtEUR(totals.distributions)}</span>
|
||||
{platYear && <TrendBadge current={totals.distributions} prev={prevTotals.distributions} />}
|
||||
</div>
|
||||
{platYear && <div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{fmtEUR(prevTotals.distributions)} en {prevYear}</div>}
|
||||
</div>
|
||||
|
||||
{/* 5 — TVPI moyen */}
|
||||
<div className="kpi" title="Multiple pondéré du portefeuille : (valorisation + distributions) / capital investi">
|
||||
<div className="label">TVPI moyen</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
|
||||
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fmtMultiple(totals.tvpi)}</span>
|
||||
{platYear && totals.tvpi != null && <TrendBadge current={totals.tvpi} prev={prevTotals.tvpi} />}
|
||||
</div>
|
||||
{platYear && <div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{fmtMultiple(prevTotals.tvpi)} en {prevYear}</div>}
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
{/* ── Onglets ── */}
|
||||
{!listFocused && <div className="dr-tabs">
|
||||
<button className={`dr-tab${activeTab === 'plateformes' ? ' active' : ''}`}
|
||||
onClick={() => setActiveTab('plateformes')}>Plateformes</button>
|
||||
<button className={`dr-tab${activeTab === 'vision-mensuelle' ? ' active' : ''}`}
|
||||
onClick={() => setActiveTab('vision-mensuelle')}>Vision mensuelle</button>
|
||||
<button className={`dr-tab${activeTab === 'investissements' ? ' active' : ''}`}
|
||||
onClick={() => setActiveTab('investissements')}>Investissements</button>
|
||||
</div>}
|
||||
|
||||
{/* ====== ONGLET PLATEFORMES ====== */}
|
||||
{activeTab === 'plateformes' && (
|
||||
<div style={{ padding: '0 24px' }}>
|
||||
<div className="card" style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
|
||||
<h3 style={{ margin: 0 }}>
|
||||
Répartition par plateforme
|
||||
{platYear && <span style={{ marginLeft: 8 }}>pour l'année {platYear}</span>}
|
||||
</h3>
|
||||
<select value={platYear} onChange={e => setPlatYear(e.target.value)}
|
||||
style={{
|
||||
fontSize: 'var(--fs-xs)', padding: '4px 8px', height: 30,
|
||||
borderRadius: 6, border: '1px solid var(--border)',
|
||||
background: 'var(--surface-2)', color: 'var(--text-muted)',
|
||||
cursor: 'pointer', outline: 'none',
|
||||
}}>
|
||||
<option value="">Toutes les années</option>
|
||||
{years.map(y => <option key={y} value={y}>{y}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
{!rows.length && loading ? (
|
||||
<div className="text-muted" style={{ padding: '12px 0' }}>Chargement…</div>
|
||||
) : (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Plateforme</th>
|
||||
{multiDetenteur && <th>Détenteur</th>}
|
||||
<th className="num">Deals</th>
|
||||
<th className="num">Montant investi</th>
|
||||
<th className="num">Valorisation actuelle</th>
|
||||
<th className="num">Distributions</th>
|
||||
<th className="num">TVPI</th>
|
||||
<th className="num">Poids</th>
|
||||
<th style={{ width: 28 }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{platData.length === 0 && (
|
||||
<tr><td colSpan={multiDetenteur ? 9 : 8} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>
|
||||
Aucun investissement
|
||||
</td></tr>
|
||||
)}
|
||||
{platData.map(p => {
|
||||
const isActive = String(filter.plateforme_id) === String(p.plateforme_id);
|
||||
return (
|
||||
<tr key={p.plateforme_id}
|
||||
className={`dr-row${isActive ? ' dr-row-selected' : ''}`}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
setFilter(f => ({ ...f, plateforme_id: isActive ? '' : String(p.plateforme_id) }));
|
||||
setActiveTab('investissements');
|
||||
}}>
|
||||
<td><span style={{ fontWeight: isActive ? 600 : undefined }}>{p.nom}</span></td>
|
||||
{multiDetenteur && <td className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>{p.detenteur_nom || '—'}</td>}
|
||||
<td className="num">{p.count}</td>
|
||||
<td className="num">{fmtEUR(p.investi)}</td>
|
||||
<td className="num">{fmtEUR(p.valorisation)}</td>
|
||||
<td className="num">{fmtEUR(p.distributions)}</td>
|
||||
<td className="num">{fmtMultiple(p.tvpi)}</td>
|
||||
<td className="num">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, justifyContent: 'flex-end' }}>
|
||||
<div style={{ width: 48, height: 5, borderRadius: 3, background: 'var(--surface-2)', overflow: 'hidden' }}>
|
||||
<div style={{ width: `${Math.max(0, p.poids)}%`, height: '100%', background: 'var(--primary)', borderRadius: 3 }} />
|
||||
</div>
|
||||
<span style={{ minWidth: 36, textAlign: 'right', fontSize: 'var(--fs-sm)' }}>{p.poids.toFixed(1)} %</span>
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ textAlign: 'center', color: 'var(--text-muted)', fontSize: 11 }}>{isActive ? '✕' : ''}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
{platData.length > 1 && (
|
||||
<tfoot>
|
||||
<tr style={{ borderTop: '2px solid var(--border)', fontWeight: 700, background: 'var(--surface-2)' }}>
|
||||
<td>Total — {platData.length} plateformes</td>
|
||||
{multiDetenteur && <td />}
|
||||
<td className="num">{platTotals.count}</td>
|
||||
<td className="num">{fmtEUR(platTotals.investi)}</td>
|
||||
<td className="num">{fmtEUR(platTotals.valorisation)}</td>
|
||||
<td className="num">{fmtEUR(platTotals.distributions)}</td>
|
||||
<td className="num">{fmtMultiple(platTotals.tvpi)}</td>
|
||||
<td className="num"><span style={{ fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>100 %</span></td>
|
||||
<td />
|
||||
</tr>
|
||||
</tfoot>
|
||||
)}
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ====== ONGLET VISION MENSUELLE ====== */}
|
||||
{activeTab === 'vision-mensuelle' && (
|
||||
<div style={{ padding: '0 24px' }}>
|
||||
<ValorisationMensuelleTable rows={rows} valorisations={valorisations} plats={plats} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ====== ONGLET INVESTISSEMENTS ====== */}
|
||||
{activeTab === 'investissements' && (
|
||||
<div style={{ padding: '0 24px' }}>
|
||||
<div className="card">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 14 }}>
|
||||
<h3 style={{ margin: 0 }}>
|
||||
Liste des investissements
|
||||
{filteredRows.length !== sorted.length && (
|
||||
<span style={{ marginLeft: 8, fontSize: 'var(--fs-xs)', fontWeight: 400, color: 'var(--text-muted)' }}>
|
||||
{filteredRows.length} / {sorted.length}
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
<button className="primary" onClick={openAdd}>+ Nouvel investissement</button>
|
||||
</div>
|
||||
|
||||
{/* Filtres */}
|
||||
<div className="row" style={{ marginBottom: 12, gap: 8 }}>
|
||||
<div style={{ flex: 1, minWidth: 130 }}>
|
||||
<label>Statut</label>
|
||||
<select value={filter.statut} onChange={e => setFilter(f => ({ ...f, statut: e.target.value }))}>
|
||||
<option value="">Tous</option>
|
||||
<option value="en_attente">En attente</option>
|
||||
<option value="valide">Validé</option>
|
||||
<option value="cloture">Clôturé</option>
|
||||
<option value="perte_definitive">Perte définitive</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 130 }}>
|
||||
<label>Plateforme</label>
|
||||
<select value={filter.plateforme_id} onChange={e => setFilter(f => ({ ...f, plateforme_id: e.target.value }))}>
|
||||
<option value="">Toutes</option>
|
||||
{plats.map(p => <option key={p.id} value={p.id}>{p.nom}{multiDetenteur && p.investisseur_nom ? ` — ${p.investisseur_nom}` : ''}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Deal</th><th>Plateforme</th>{multiDetenteur && <th>Détenteur</th>}
|
||||
<th>Stratégie</th><th>Date</th><th className="num">Montant investi</th>
|
||||
<th className="num">Dernière valorisation</th><th className="num">TVPI</th>
|
||||
<th>Objectif</th><th>Statut</th><th style={{ width: 70 }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredRows.length === 0 && (
|
||||
<tr><td colSpan={multiDetenteur ? 11 : 10} className="text-muted" style={{ textAlign: 'center' }}>Aucun investissement</td></tr>
|
||||
)}
|
||||
{filteredRows.map(r => (
|
||||
<tr key={r.id} className="dr-row" style={{ cursor: 'pointer' }} onClick={() => navigate(`/investissements/${r.id}`)}>
|
||||
<td>{r.nom_deal}</td>
|
||||
<td>{r.plateforme_nom}</td>
|
||||
{multiDetenteur && <td className="text-muted">{r.investisseur_nom || '—'}</td>}
|
||||
<td className="text-muted">{r.strategie || '—'}</td>
|
||||
<td>{fmtDate(r.date_souscription)}</td>
|
||||
<td className="num">{fmtEUR(r.montant_investi)}</td>
|
||||
<td className="num">{r.derniere_valorisation != null ? fmtEUR(r.derniere_valorisation) : '—'}</td>
|
||||
<td className="num">{fmtMultiple(r.tvpi)}</td>
|
||||
<td className="text-muted">{r.objectif || '—'}</td>
|
||||
<td className="text-muted">{STATUT_LABELS[r.statut] || r.statut}</td>
|
||||
<td onClick={e => e.stopPropagation()}>
|
||||
<button className="icon-btn" title="Ajouter un frais" onClick={() => openFraisAdd(r)}>€</button>
|
||||
<button className="icon-btn" title="Supprimer" onClick={() => setDeleteTarget(r)}>✕</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<InvestissementPeFormModal
|
||||
open={modalOpen}
|
||||
editingId={editingId}
|
||||
onClose={close}
|
||||
form={form}
|
||||
setForm={setForm}
|
||||
plats={plats}
|
||||
investisseurs={investisseurs}
|
||||
multiDetenteur={multiDetenteur}
|
||||
comptesInvestisseur={comptesInvestisseur}
|
||||
err={err}
|
||||
submitting={submitting}
|
||||
onSubmit={() => runSubmit(submit)}
|
||||
onDelete={() => { const target = { id: editingId }; close(); setDeleteTarget(target); }}
|
||||
investisseurForPlat={investisseurForPlat}
|
||||
/>
|
||||
|
||||
<ConfirmModal
|
||||
open={!!deleteTarget}
|
||||
title="Supprimer cet investissement ?"
|
||||
message="Cette action est irréversible. Les distributions déjà saisies (Remboursements) sont conservées mais détachées de ce deal."
|
||||
confirmLabel="Supprimer"
|
||||
onConfirm={onDelete}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
/>
|
||||
|
||||
{/* ── Modale annexe : "+ frais" rapide depuis une ligne de la liste ── */}
|
||||
<FraisFormModal
|
||||
open={!!fraisAddDeal}
|
||||
onClose={closeFraisAdd}
|
||||
editingId={null}
|
||||
form={fraisAddForm}
|
||||
onFieldChange={(k, v) => setFraisAddForm(f => ({ ...f, [k]: v }))}
|
||||
err={fraisAddErr}
|
||||
onSubmit={e => runFraisAddSubmit(submitFraisAdd, e)}
|
||||
onDelete={null}
|
||||
comptesInvestisseur={fraisAddComptes}
|
||||
locked
|
||||
investissementOptions={[]}
|
||||
confirmingDelete={false}
|
||||
setConfirmingDelete={() => {}}
|
||||
submitting={fraisAddSubmitting}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { InteretsChartProvider } from '../context/InteretsChartContext.jsx';
|
||||
import { fmtEUR, fmtDate, fmtStatut, memberLabel, today } from '../utils/format.js';
|
||||
import { xirr } from '../utils/xirr.js';
|
||||
import { usePagination } from '../hooks/usePagination.js';
|
||||
import { useAsyncAction } from '../hooks/useAsyncAction.js';
|
||||
import Pagination from '../components/Pagination.jsx';
|
||||
import Modal from '../components/Modal.jsx';
|
||||
import ConfirmModal from '../components/ConfirmModal.jsx';
|
||||
@@ -435,6 +436,7 @@ export default function Plateformes() {
|
||||
const [rembInv, setRembInv] = useState(null); // investissement lié au remb en cours d'édition
|
||||
const [rembForm, setRembForm] = useState({});
|
||||
const [rembErr, setRembErr] = useState(null);
|
||||
const { run: runSubmitRemb, pending: submittingRemb } = useAsyncAction();
|
||||
const [rembConfirmingDelete, setRembConfirmingDelete] = useState(false);
|
||||
const [comptesRembInvestisseur, setComptesRembInvestisseur] = useState([]);
|
||||
|
||||
@@ -1082,6 +1084,7 @@ export default function Plateformes() {
|
||||
const [mvEditingId, setMvEditingId] = useState(null);
|
||||
const [mvForm, setMvForm] = useState(emptyMouvementForm);
|
||||
const [mvErr, setMvErr] = useState(null);
|
||||
const { run: runMvSubmit, pending: mvSubmitting } = useAsyncAction();
|
||||
const [mvSelectedRow, setMvSelectedRow] = useState(null);
|
||||
const [mvOpenMenu, setMvOpenMenu] = useState(null); // { row, x, y }
|
||||
const [mvDeleteConfirm, setMvDeleteConfirm] = useState(null);
|
||||
@@ -3100,20 +3103,23 @@ export default function Plateformes() {
|
||||
open={mvModalOpen}
|
||||
title="Modifier le mouvement"
|
||||
onClose={mvCloseEdit}
|
||||
busy={mvSubmitting}
|
||||
footer={
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', width: '100%' }}>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button className="danger" onClick={mvOnDelete}>Supprimer</button>
|
||||
<button onClick={() => mvOpenCorrection(null)}>Correction de solde</button>
|
||||
<button className="danger" disabled={mvSubmitting} onClick={mvOnDelete}>Supprimer</button>
|
||||
<button disabled={mvSubmitting} onClick={() => mvOpenCorrection(null)}>Correction de solde</button>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button onClick={mvCloseEdit}>Annuler</button>
|
||||
<button className="primary" onClick={mvSubmit}>Enregistrer</button>
|
||||
<button disabled={mvSubmitting} onClick={mvCloseEdit}>Annuler</button>
|
||||
<button className="primary" disabled={mvSubmitting} onClick={() => runMvSubmit(mvSubmit)}>
|
||||
{mvSubmitting ? 'Enregistrement…' : 'Enregistrer'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<form onSubmit={mvSubmit}>
|
||||
<form onSubmit={e => runMvSubmit(mvSubmit, e)}>
|
||||
{mvErr && <div className="error">{mvErr}</div>}
|
||||
<div className="form-grid">
|
||||
<div>
|
||||
@@ -3260,8 +3266,9 @@ export default function Plateformes() {
|
||||
open={rembModalOpen}
|
||||
editingId={rembEditingId}
|
||||
onClose={closeRembModal}
|
||||
onSubmit={submitRemb}
|
||||
onDelete={onDeleteRemb}
|
||||
onSubmit={e => runSubmitRemb(submitRemb, e)}
|
||||
onDelete={() => runSubmitRemb(onDeleteRemb)}
|
||||
submitting={submittingRemb}
|
||||
confirmingDelete={rembConfirmingDelete}
|
||||
setConfirmingDelete={setRembConfirmingDelete}
|
||||
err={rembErr}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { usePagination } from '../hooks/usePagination.js';
|
||||
import { useAsyncAction } from '../hooks/useAsyncAction.js';
|
||||
import Pagination from '../components/Pagination.jsx';
|
||||
import PageIcon from '../components/PageIcon.jsx';
|
||||
import EmptyState from '../components/EmptyState.jsx';
|
||||
@@ -245,6 +246,7 @@ export default function Remboursements() {
|
||||
const [editingId, setEditingId] = useState(null);
|
||||
const [form, setForm] = useState(empty);
|
||||
const [err, setErr] = useState(null);
|
||||
const { run: runSubmit, pending: submitting } = useAsyncAction();
|
||||
const [confirmingDelete, setConfirmingDelete] = useState(false);
|
||||
const [corrDeleteConfirm, setCorrDeleteConfirm] = useState(null);
|
||||
const [comptesInvestisseur, setComptesInvestisseur] = useState([]);
|
||||
@@ -1543,8 +1545,9 @@ export default function Remboursements() {
|
||||
open={modalOpen}
|
||||
editingId={editingId}
|
||||
onClose={close}
|
||||
onSubmit={submit}
|
||||
onDelete={onDelete}
|
||||
onSubmit={e => runSubmit(submit, e)}
|
||||
onDelete={() => runSubmit(onDelete)}
|
||||
submitting={submitting}
|
||||
confirmingDelete={confirmingDelete}
|
||||
setConfirmingDelete={setConfirmingDelete}
|
||||
err={err}
|
||||
|
||||
@@ -0,0 +1,911 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useLocation, useNavigate } 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 { usePagination } from '../hooks/usePagination.js';
|
||||
import Pagination from '../components/Pagination.jsx';
|
||||
import InteretsChart from '../components/InteretsChart.jsx';
|
||||
import InteretsDistributionChart from '../components/InteretsDistributionChart.jsx';
|
||||
import * as XLSX from 'xlsx';
|
||||
import { fmtEUR, fmtDate, today } from '../utils/format.js';
|
||||
|
||||
const TYPE_LABELS = {
|
||||
bonus_plateforme: 'Bonus plateforme',
|
||||
bonus_parrainage: 'Bonus parrainage',
|
||||
interets_plateforme: 'Intérêts plateforme',
|
||||
};
|
||||
|
||||
const MOIS_FR = ['Janvier','Février','Mars','Avril','Mai','Juin','Juillet','Août','Septembre','Octobre','Novembre','Décembre'];
|
||||
const MOIS_ABBR = ['Jan','Fév','Mar','Avr','Mai','Juin','Juil','Août','Sep','Oct','Nov','Déc'];
|
||||
|
||||
/* ── Export (Excel/CSV/JSON) — même patron que Remboursements.jsx (crowdlending,
|
||||
* chantier "remboursements PE", 17/09/26, à la demande d'Olivier), colonnes
|
||||
* adaptées au modèle PE (pas de capital, lien vers un deal optionnel). ── */
|
||||
function dlBlob(content, filename, type) {
|
||||
const blob = new Blob([content], { type });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url; a.download = filename; a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function rembPeRow(r, dealNameById) {
|
||||
return {
|
||||
date_remb: r.date_remb, plateforme_nom: r.plateforme_nom || '',
|
||||
detenteur_nom: r.plateforme_detenteur_nom || '',
|
||||
type: TYPE_LABELS[r.type] || r.type,
|
||||
deal: r.investissement_pe_id ? (dealNameById[r.investissement_pe_id] || '') : '',
|
||||
cashback: r.cashback || 0, interets_bruts: r.interets_bruts || 0, frais_ttc: r.frais_ttc || 0,
|
||||
prelev_sociaux: r.prelev_sociaux || 0, prelev_forfaitaire: r.prelev_forfaitaire || 0,
|
||||
interets_nets: r.interets_nets || 0, net_recu: r.net_recu || 0,
|
||||
statut: r.statut,
|
||||
};
|
||||
}
|
||||
|
||||
function rembPeToCSV(rows, dealNameById) {
|
||||
const BOM = '\ufeff'; const sep = ';';
|
||||
const q = v => `"${String(v ?? '').replace(/"/g, '""')}"`;
|
||||
const headers = ['Date','Plateforme','Détenteur','Type','Investissement','Cashback (€)','Intérêts bruts (€)','Frais TTC (€)','Prélèv. sociaux (€)','Prélèvement forfaitaire (€)','Intérêts nets (€)','Net reçu (€)','Statut'];
|
||||
const data = rows.map(r => {
|
||||
const d = rembPeRow(r, dealNameById);
|
||||
return [
|
||||
d.date_remb, d.plateforme_nom, d.detenteur_nom, d.type, d.deal,
|
||||
String(d.cashback).replace('.', ','), String(d.interets_bruts).replace('.', ','), String(d.frais_ttc).replace('.', ','),
|
||||
String(d.prelev_sociaux).replace('.', ','), String(d.prelev_forfaitaire).replace('.', ','),
|
||||
String(d.interets_nets).replace('.', ','), String(d.net_recu).replace('.', ','), d.statut,
|
||||
];
|
||||
});
|
||||
return BOM + [headers, ...data].map(r => r.map(q).join(sep)).join('\r\n');
|
||||
}
|
||||
|
||||
function rembPeToXLS(rows, dealNameById) {
|
||||
const data = rows.map(r => {
|
||||
const d = rembPeRow(r, dealNameById);
|
||||
return {
|
||||
'Date': d.date_remb, 'Plateforme': d.plateforme_nom, 'Détenteur': d.detenteur_nom, 'Type': d.type,
|
||||
'Investissement': d.deal, 'Cashback (€)': d.cashback, 'Intérêts bruts (€)': d.interets_bruts,
|
||||
'Frais TTC (€)': d.frais_ttc, 'Prélèv. sociaux (€)': d.prelev_sociaux, 'Prélèvement forfaitaire (€)': d.prelev_forfaitaire,
|
||||
'Intérêts nets (€)': d.interets_nets, 'Net reçu (€)': d.net_recu, 'Statut': d.statut,
|
||||
};
|
||||
});
|
||||
const ws = XLSX.utils.json_to_sheet(data);
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, ws, 'Remboursements');
|
||||
return XLSX.write(wb, { type: 'array', bookType: 'xlsx' });
|
||||
}
|
||||
|
||||
function rembPeToJSON(rows, dealNameById) {
|
||||
return JSON.stringify(rows.map(r => rembPeRow(r, dealNameById)), null, 2);
|
||||
}
|
||||
|
||||
/* ── Menu déroulant d'export (CSV/Excel/JSON) — identique au composant de
|
||||
* Remboursements.jsx : rien ici n'est spécifique crowdlending. ── */
|
||||
function ExportDropdown({ disabled, onCSV, onXLS, onJSON }) {
|
||||
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 choose = fn => { setOpen(false); fn(); };
|
||||
return (
|
||||
<div ref={ref} style={{ position: 'relative' }}>
|
||||
<button type="button" className="icon-btn" disabled={disabled} onClick={() => setOpen(o => !o)} title="Exporter">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>
|
||||
</svg>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="export-dropdown" role="menu">
|
||||
<button role="menuitem" onClick={() => choose(onCSV)}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>
|
||||
<line x1="8" y1="13" x2="16" y2="13"/><line x1="8" y1="17" x2="16" y2="17"/>
|
||||
</svg>
|
||||
<span><strong>Format CSV</strong><small>Compatible Excel, LibreOffice</small></span>
|
||||
</button>
|
||||
<button role="menuitem" onClick={() => choose(onXLS)}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><path d="M9 13l2 2 4-4"/>
|
||||
</svg>
|
||||
<span><strong>Format Excel</strong><small>Fichier .xlsx Microsoft Excel</small></span>
|
||||
</button>
|
||||
<button role="menuitem" onClick={() => choose(onJSON)}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>
|
||||
<path d="M8 13h1.5a1 1 0 0 1 1 1v1a1 1 0 0 0 1 1 1 1 0 0 0-1 1v1a1 1 0 0 1-1 1H8"/>
|
||||
<path d="M16 13h-1.5a1 1 0 0 0-1 1v1a1 1 0 0 1-1 1 1 1 0 0 1 1 1v1a1 1 0 0 0 1 1H16"/>
|
||||
</svg>
|
||||
<span><strong>Format JSON</strong><small>Réimportable, structuré</small></span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const emptyRembForm = {
|
||||
type: 'bonus_plateforme',
|
||||
bonus_plateforme_id: '',
|
||||
bonus_investisseur_id: '',
|
||||
// Lien optionnel vers un deal PE (investissements_pe, 17/09/26) : permet de
|
||||
// rattacher une distribution à un investissement précis pour calculer son
|
||||
// TVPI, quand plusieurs deals partagent une même plateforme (ex. Fundora).
|
||||
investissement_pe_id: '',
|
||||
date_remb: today(),
|
||||
statut: 'paye',
|
||||
notes: '',
|
||||
// Bonus (parrainage / plateforme)
|
||||
cashback: '',
|
||||
// Intérêts plateforme
|
||||
interets_bruts_avant_local: '',
|
||||
taxe_locale: '',
|
||||
interets_bruts: '',
|
||||
frais_ttc: '',
|
||||
prelev_sociaux: '',
|
||||
prelev_forfaitaire: '',
|
||||
};
|
||||
|
||||
/**
|
||||
* Remboursements — version simplifiée, utilisée par tout workspace autre que
|
||||
* crowdlending (Private Equity pour l'instant — cf. RemboursementsRouter dans
|
||||
* App.jsx, jamais lié en dur à un slug précis), même patron que
|
||||
* DepotsRetraitsSimple.jsx.
|
||||
*
|
||||
* Un remboursement "normal" (type par défaut de pages/Remboursements.jsx) est
|
||||
* obligatoirement rattaché à un investissement (investissement_id), or la
|
||||
* table investissements n'a pas encore de workspace_id et sa page reste
|
||||
* réservée à crowdlending (cf. project_workspaces_transformation.md, Étape 2
|
||||
* à venir table par table). Cette page se limite donc, à la demande
|
||||
* d'Olivier (17/09/26), aux 3 types de remboursement qui ne dépendent que
|
||||
* d'une plateforme + d'un détenteur : bonus parrainage, bonus plateforme,
|
||||
* intérêts plateforme — déjà pleinement utilisables puisque plateformes
|
||||
* supporte le multi-workspace (N-N) depuis une étape précédente.
|
||||
*
|
||||
* L'endpoint (/remboursements) est directement partagé avec la page
|
||||
* crowdlending, déjà flêché par workspace côté serveur via l'en-tête
|
||||
* X-Workspace-Id (cf. workspaceScope.js, Étape 2a/2c du 17/09/26).
|
||||
*/
|
||||
export default function RemboursementsSimple() {
|
||||
const { activeId: investisseurId, investisseurs } = useInvestisseur();
|
||||
const { active: activeWorkspace } = useWorkspace() || {};
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [rows, setRows] = useState([]);
|
||||
const [allPlats, setAllPlats] = useState([]);
|
||||
const [deals, setDeals] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [err, setErr] = useState(null);
|
||||
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState(null);
|
||||
const [form, setForm] = useState(emptyRembForm);
|
||||
const [deleteTarget, setDeleteTarget] = useState(null);
|
||||
const { run: runSubmit, pending: submitting } = useAsyncAction();
|
||||
|
||||
// Plateformes éligibles au workspace actif uniquement (GET /plateformes
|
||||
// n'est pas filtré côté serveur — seulement enrichi de row.workspaces),
|
||||
// même patron que DepotsRetraitsSimple.
|
||||
const plats = useMemo(
|
||||
() => allPlats.filter(p => (p.workspaces || []).some(w => w.id === activeWorkspace?.id)),
|
||||
[allPlats, activeWorkspace],
|
||||
);
|
||||
|
||||
const multiDetenteur = new Set(plats.map(p => p.investisseur_id)).size > 1;
|
||||
|
||||
// ── Onglets + filtres (chantier "remboursements PE", 17/09/26 — rapprochement de
|
||||
// la page Remboursements crowdlending, à la demande d'Olivier) : Plateformes
|
||||
// (graphiques + répartition), Vision mensuelle, Remboursements (liste filtrable,
|
||||
// paginée, exportable). Pas d'onglets "Mois en cours"/"Projections" : ils reposent
|
||||
// sur un échéancier prévisionnel qui n'existe pas pour un deal PE.
|
||||
const [activeTab, setActiveTab] = useState('plateformes');
|
||||
const [rembPlatYear, setRembPlatYear] = useState('');
|
||||
const [vmYear, setVmYear] = useState('');
|
||||
const [tableFilter, setTableFilter] = useState({ plateforme_id: '', type: '', investissement_pe_id: '', year: '', month: '' });
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [rb, pl, dl] = await Promise.all([
|
||||
api.get('/remboursements'),
|
||||
api.get('/plateformes'),
|
||||
api.get('/investissements-pe'),
|
||||
]);
|
||||
setRows(rb);
|
||||
setAllPlats(pl);
|
||||
setDeals(dl);
|
||||
} catch (e) {
|
||||
setErr(e.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (investisseurId) load();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [investisseurId, activeWorkspace?.id]);
|
||||
|
||||
const investisseurForPlat = (platId) => {
|
||||
const plat = plats.find(p => String(p.id) === String(platId));
|
||||
return plat?.investisseur_id ? String(plat.investisseur_id) : String(investisseurId || '');
|
||||
};
|
||||
|
||||
const openAdd = () => {
|
||||
setEditingId(null);
|
||||
const firstId = plats[0]?.id || '';
|
||||
setForm({ ...emptyRembForm, bonus_plateforme_id: firstId, bonus_investisseur_id: investisseurForPlat(firstId) });
|
||||
setErr(null);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
// Ouverture auto depuis le bouton global "Nouveau remboursement" (topbar/burger,
|
||||
// cf. Layout.jsx — même ?new=1 que la page crowdlending et que Dépôts/Retraits).
|
||||
const pendingNew = useRef(false);
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
if (params.get('new') !== '1') return;
|
||||
navigate('/remboursements', { replace: true });
|
||||
if (plats.length > 0) {
|
||||
openAdd();
|
||||
} else {
|
||||
pendingNew.current = true;
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [location.search]);
|
||||
|
||||
useEffect(() => {
|
||||
if (pendingNew.current && plats.length > 0) {
|
||||
pendingNew.current = false;
|
||||
openAdd();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [plats.length]);
|
||||
|
||||
// Le type n'est pas modifiable en édition : le backend choisit sa branche
|
||||
// d'UPDATE (et donc les colonnes réellement écrites) d'après form.type
|
||||
// soumis, pas d'après le type déjà stocké en base — changer de type en
|
||||
// cours d'édition désynchroniserait la ligne. On fige donc le type choisi
|
||||
// à la création.
|
||||
const openEdit = (r) => {
|
||||
setEditingId(r.id);
|
||||
setForm({
|
||||
type: r.type,
|
||||
bonus_plateforme_id: String(r.bonus_plateforme_id || ''),
|
||||
bonus_investisseur_id: String(r.bonus_investisseur_id || ''),
|
||||
investissement_pe_id: r.investissement_pe_id ? String(r.investissement_pe_id) : '',
|
||||
date_remb: r.date_remb,
|
||||
statut: r.statut,
|
||||
notes: r.notes || '',
|
||||
cashback: String(r.cashback ?? ''),
|
||||
interets_bruts_avant_local: String(r.interets_bruts_avant_local ?? ''),
|
||||
taxe_locale: String(r.taxe_locale ?? ''),
|
||||
interets_bruts: String(r.interets_bruts ?? ''),
|
||||
frais_ttc: String(r.frais_ttc ?? ''),
|
||||
prelev_sociaux: String(r.prelev_sociaux ?? ''),
|
||||
prelev_forfaitaire: String(r.prelev_forfaitaire ?? ''),
|
||||
});
|
||||
setErr(null);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const close = () => { setModalOpen(false); setEditingId(null); setForm(emptyRembForm); setErr(null); };
|
||||
|
||||
const submit = async (e) => {
|
||||
e?.preventDefault?.(); setErr(null);
|
||||
const base = {
|
||||
type: form.type,
|
||||
bonus_plateforme_id: Number(form.bonus_plateforme_id),
|
||||
bonus_investisseur_id: Number(form.bonus_investisseur_id) || undefined,
|
||||
investissement_pe_id: Number(form.investissement_pe_id) || undefined,
|
||||
date_remb: form.date_remb,
|
||||
statut: form.statut,
|
||||
notes: form.notes || undefined,
|
||||
};
|
||||
const payload = form.type === 'interets_plateforme'
|
||||
? {
|
||||
...base,
|
||||
interets_bruts_avant_local: Number(form.interets_bruts_avant_local) || 0,
|
||||
taxe_locale: Number(form.taxe_locale) || 0,
|
||||
interets_bruts: Number(form.interets_bruts) || 0,
|
||||
frais_ttc: Number(form.frais_ttc) || 0,
|
||||
prelev_sociaux: Number(form.prelev_sociaux) || 0,
|
||||
prelev_forfaitaire: Number(form.prelev_forfaitaire) || 0,
|
||||
}
|
||||
: {
|
||||
...base,
|
||||
cashback: Number(form.cashback) || 0,
|
||||
};
|
||||
try {
|
||||
if (editingId) await api.put(`/remboursements/${editingId}`, payload);
|
||||
else await api.post('/remboursements', payload);
|
||||
close();
|
||||
await load();
|
||||
} catch (e) { setErr(e.message); }
|
||||
};
|
||||
|
||||
const onDelete = async () => {
|
||||
if (!deleteTarget) return;
|
||||
try {
|
||||
await api.del(`/remboursements/${deleteTarget.id}`);
|
||||
setDeleteTarget(null);
|
||||
await load();
|
||||
} catch (e) { setErr(e.message); }
|
||||
};
|
||||
|
||||
const sorted = useMemo(
|
||||
() => [...rows].sort((a, b) => b.date_remb.localeCompare(a.date_remb) || b.id - a.id),
|
||||
[rows],
|
||||
);
|
||||
|
||||
const statutLabel = (s) => ({ paye: 'Payé', retard: 'En retard', partiel: 'Partiel', impaye: 'Impayé' }[s] || s);
|
||||
|
||||
const years = useMemo(
|
||||
() => [...new Set(rows.map(r => r.date_remb.slice(0, 4)))].sort().reverse(),
|
||||
[rows],
|
||||
);
|
||||
|
||||
const dealNameById = useMemo(() => {
|
||||
const map = {};
|
||||
for (const d of deals) map[d.id] = d.nom_deal;
|
||||
return map;
|
||||
}, [deals]);
|
||||
|
||||
/* ── Onglet Plateformes : graphiques + répartition ──────────────────────
|
||||
* InteretsChart/InteretsDistributionChart (crowdlending) ne lisent que
|
||||
* date_remb + interets_nets/interets_bruts — génériques, réutilisés tels
|
||||
* quels. Le modèle PE n'a pas de bascule brut/net au niveau agrégat (pas
|
||||
* de fiscalité à la source recalculée ici) : on aligne les deux champs sur
|
||||
* net_recu (cashback + intérêts nets déjà calculés côté backend) pour que
|
||||
* la courbe/le treemap représentent le total réellement perçu. */
|
||||
const netMode = true;
|
||||
const chartRows = useMemo(() => {
|
||||
const src = rembPlatYear ? rows.filter(r => r.date_remb.slice(0, 4) === rembPlatYear) : rows;
|
||||
return src.map(r => ({ ...r, interets_nets: r.net_recu, interets_bruts: r.net_recu }));
|
||||
}, [rows, rembPlatYear]);
|
||||
|
||||
const byPlatform = useMemo(() => {
|
||||
const src = rembPlatYear ? rows.filter(r => r.date_remb.slice(0, 4) === rembPlatYear) : rows;
|
||||
const map = {};
|
||||
for (const r of src) {
|
||||
const key = String(r.plateforme_id);
|
||||
if (!map[key]) map[key] = {
|
||||
plateforme_id: r.plateforme_id, nom: r.plateforme_nom,
|
||||
detenteur_nom: r.plateforme_detenteur_nom || null,
|
||||
cashback: 0, interets_nets: 0, net_recu: 0, count: 0,
|
||||
};
|
||||
map[key].cashback += r.cashback || 0;
|
||||
map[key].interets_nets += r.interets_nets || 0;
|
||||
map[key].net_recu += r.net_recu || 0;
|
||||
map[key].count++;
|
||||
}
|
||||
return Object.values(map).sort((a, b) => b.net_recu - a.net_recu);
|
||||
}, [rows, rembPlatYear]);
|
||||
|
||||
const byPlatformTotals = useMemo(() => byPlatform.reduce((acc, p) => {
|
||||
acc.cashback += p.cashback; acc.interets_nets += p.interets_nets;
|
||||
acc.net_recu += p.net_recu; acc.count += p.count;
|
||||
return acc;
|
||||
}, { cashback: 0, interets_nets: 0, net_recu: 0, count: 0 }), [byPlatform]);
|
||||
|
||||
const totalNetRecu = byPlatformTotals.net_recu;
|
||||
|
||||
/* ── Onglet Vision mensuelle : grille Plateforme × mois, année sélectionnée.
|
||||
* Pas de composant réutilisable côté crowdlending (TableauInteretsPlateforme
|
||||
* dépend d'un endpoint + contexte bâtis autour d'un échéancier réel/projeté
|
||||
* qui n'existe pas pour un deal PE) — grille reconstruite ici, client-side,
|
||||
* à partir des lignes déjà chargées. ── */
|
||||
const effectiveVmYear = vmYear || years[0] || String(new Date().getFullYear());
|
||||
const vmData = useMemo(() => {
|
||||
const map = {};
|
||||
for (const r of rows) {
|
||||
if (r.date_remb.slice(0, 4) !== effectiveVmYear) continue;
|
||||
const key = String(r.plateforme_id);
|
||||
if (!map[key]) map[key] = { plateforme_id: r.plateforme_id, nom: r.plateforme_nom, months: Array(12).fill(0), total: 0 };
|
||||
const m = Number(r.date_remb.slice(5, 7)) - 1;
|
||||
map[key].months[m] += r.net_recu || 0;
|
||||
map[key].total += r.net_recu || 0;
|
||||
}
|
||||
return Object.values(map).sort((a, b) => b.total - a.total);
|
||||
}, [rows, effectiveVmYear]);
|
||||
|
||||
const vmMonthTotals = useMemo(() => {
|
||||
const totals = Array(12).fill(0);
|
||||
for (const p of vmData) for (let i = 0; i < 12; i++) totals[i] += p.months[i];
|
||||
return totals;
|
||||
}, [vmData]);
|
||||
|
||||
const vmGrandTotal = vmMonthTotals.reduce((s, v) => s + v, 0);
|
||||
|
||||
/* ── Onglet Remboursements : liste filtrable, paginée, exportable ── */
|
||||
const tableRows = useMemo(() => sorted.filter(r => {
|
||||
if (tableFilter.plateforme_id && String(r.plateforme_id) !== tableFilter.plateforme_id) return false;
|
||||
if (tableFilter.type && r.type !== tableFilter.type) return false;
|
||||
if (tableFilter.investissement_pe_id && String(r.investissement_pe_id || '') !== tableFilter.investissement_pe_id) return false;
|
||||
if (tableFilter.year && r.date_remb.slice(0, 4) !== tableFilter.year) return false;
|
||||
if (tableFilter.month && r.date_remb.slice(5, 7) !== tableFilter.month) return false;
|
||||
return true;
|
||||
}), [sorted, tableFilter]);
|
||||
|
||||
const {
|
||||
pagedItems: pagedTableRows, page: rembPage, setPage: setRembPage,
|
||||
pageSize: rembPageSize, setPageSize: setRembPageSize,
|
||||
totalPages: rembTotalPages, totalItems: rembTotalItems, PAGE_SIZES,
|
||||
} = usePagination(tableRows, 'cl_pagesize_remb_pe', [tableFilter]);
|
||||
|
||||
if (!loading && plats.length === 0) return (
|
||||
<>
|
||||
<div className="topbar"><h2><PageIcon name="remboursement" />Remboursements</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 pour pouvoir y enregistrer des remboursements.`}
|
||||
cta="Configurer mes plateformes"
|
||||
to="/settings?section=plateformes&openAdd=1"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
if (!loading && !modalOpen && sorted.length === 0) return (
|
||||
<>
|
||||
<div className="topbar"><h2><PageIcon name="remboursement" />Remboursements</h2></div>
|
||||
<EmptyState
|
||||
icon="💰"
|
||||
title="Aucun remboursement enregistré"
|
||||
message="Vos plateformes sont configurées, mais aucun remboursement n'a encore été enregistré."
|
||||
cta="Ajouter un remboursement"
|
||||
to="/remboursements?new=1"
|
||||
/>
|
||||
<Modal
|
||||
open={modalOpen}
|
||||
title={editingId ? 'Modifier le remboursement' : 'Nouveau remboursement'}
|
||||
onClose={close}
|
||||
busy={submitting}
|
||||
footer={
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button disabled={submitting} onClick={close}>Annuler</button>
|
||||
<button className="primary" disabled={submitting} onClick={() => runSubmit(submit)}>
|
||||
{submitting ? 'Création…' : 'Créer'}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<RemboursementForm form={form} setForm={setForm} plats={plats} investisseurs={investisseurs} deals={deals}
|
||||
multiDetenteur={multiDetenteur} err={err} runSubmit={runSubmit} submit={submit}
|
||||
investisseurForPlat={investisseurForPlat} editingId={editingId} />
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="topbar">
|
||||
<h2><PageIcon name="remboursement" />Remboursements</h2>
|
||||
<button className="primary" onClick={openAdd}>+ Nouveau remboursement</button>
|
||||
</div>
|
||||
|
||||
{err && <div className="error" style={{ margin: '0 0 16px' }}>{err}</div>}
|
||||
|
||||
<div className="charts-row">
|
||||
<InteretsChart rows={chartRows} netMode={netMode} />
|
||||
<InteretsDistributionChart rows={chartRows} netMode={netMode} />
|
||||
</div>
|
||||
|
||||
<div className="dr-tabs">
|
||||
<button className={`dr-tab${activeTab === 'plateformes' ? ' active' : ''}`}
|
||||
onClick={() => setActiveTab('plateformes')}>Plateformes</button>
|
||||
<button className={`dr-tab${activeTab === 'vision-mensuelle' ? ' active' : ''}`}
|
||||
onClick={() => setActiveTab('vision-mensuelle')}>Vision mensuelle</button>
|
||||
<button className={`dr-tab${activeTab === 'remboursements' ? ' active' : ''}`}
|
||||
onClick={() => setActiveTab('remboursements')}>Remboursements</button>
|
||||
</div>
|
||||
|
||||
{/* ══ ONGLET PLATEFORMES ══ */}
|
||||
{activeTab === 'plateformes' && (
|
||||
<div style={{ padding: '0 24px' }}>
|
||||
<div className="card">
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
|
||||
<h3 style={{ margin: 0 }}>
|
||||
Répartition par plateforme
|
||||
{rembPlatYear && <span style={{ marginLeft: 8 }}>pour l'année {rembPlatYear}</span>}
|
||||
</h3>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<select value={rembPlatYear} onChange={e => setRembPlatYear(e.target.value)}
|
||||
style={{ fontSize: 'var(--fs-xs)', padding: '4px 8px', height: 30, borderRadius: 6, border: '1px solid var(--border)', background: 'var(--surface-2)', color: 'var(--text-muted)', cursor: 'pointer', outline: 'none' }}>
|
||||
<option value="">Toutes les années</option>
|
||||
{years.map(y => <option key={y} value={y}>{y}</option>)}
|
||||
</select>
|
||||
<ExportDropdown
|
||||
disabled={byPlatform.length === 0}
|
||||
onCSV={() => dlBlob(rembPeToCSV(rows, dealNameById), 'remboursements.csv', 'text/csv;charset=utf-8')}
|
||||
onXLS={() => dlBlob(rembPeToXLS(rows, dealNameById), 'remboursements.xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')}
|
||||
onJSON={() => dlBlob(rembPeToJSON(rows, dealNameById), 'remboursements.json', 'application/json')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Plateforme</th>
|
||||
{multiDetenteur && <th>Détenteur</th>}
|
||||
<th className="num">Cashback</th>
|
||||
<th className="num">Intérêts (nets)</th>
|
||||
<th className="num">Total net perçu</th>
|
||||
<th className="num">Mouvements</th>
|
||||
<th className="num" style={{ minWidth: 120 }}>Poids</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{byPlatform.length === 0 && (
|
||||
<tr><td colSpan={multiDetenteur ? 7 : 6} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>
|
||||
{loading ? 'Chargement…' : 'Aucun remboursement'}
|
||||
</td></tr>
|
||||
)}
|
||||
{byPlatform.map(p => {
|
||||
const poids = totalNetRecu !== 0 ? (p.net_recu / totalNetRecu) * 100 : 0;
|
||||
return (
|
||||
<tr key={p.plateforme_id} className="dr-row" style={{ cursor: 'pointer' }}
|
||||
onClick={() => { setTableFilter(f => ({ ...f, plateforme_id: String(p.plateforme_id) })); setActiveTab('remboursements'); }}
|
||||
title="Cliquer pour voir le détail dans l'onglet Remboursements"
|
||||
>
|
||||
<td>{p.nom}</td>
|
||||
{multiDetenteur && <td className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>{p.detenteur_nom || '—'}</td>}
|
||||
<td className="num">{p.cashback > 0 ? fmtEUR(p.cashback) : '—'}</td>
|
||||
<td className="num">{p.interets_nets > 0 ? fmtEUR(p.interets_nets) : '—'}</td>
|
||||
<td className="num">{fmtEUR(p.net_recu)}</td>
|
||||
<td className="num" style={{ color: 'var(--text-muted)', fontSize: 'var(--fs-sm)' }}>{p.count}</td>
|
||||
<td className="num">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, justifyContent: 'flex-end' }}>
|
||||
<div style={{ width: 48, height: 5, borderRadius: 3, background: 'var(--surface-2)', overflow: 'hidden' }}>
|
||||
<div style={{ width: `${Math.max(0, poids)}%`, height: '100%', background: 'var(--primary)', borderRadius: 3 }} />
|
||||
</div>
|
||||
<span style={{ minWidth: 36, textAlign: 'right', fontSize: 'var(--fs-sm)' }}>{poids.toFixed(1)} %</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
{byPlatform.length > 1 && (
|
||||
<tfoot>
|
||||
<tr style={{ borderTop: '2px solid var(--border)', fontWeight: 700, background: 'var(--surface-2)' }}>
|
||||
<td>Total — {byPlatform.length} plateformes</td>
|
||||
{multiDetenteur && <td />}
|
||||
<td className="num">{byPlatformTotals.cashback > 0 ? fmtEUR(byPlatformTotals.cashback) : '—'}</td>
|
||||
<td className="num">{byPlatformTotals.interets_nets > 0 ? fmtEUR(byPlatformTotals.interets_nets) : '—'}</td>
|
||||
<td className="num">{fmtEUR(byPlatformTotals.net_recu)}</td>
|
||||
<td className="num" style={{ color: 'var(--text-muted)', fontSize: 'var(--fs-sm)' }}>{byPlatformTotals.count}</td>
|
||||
<td className="num"><span style={{ fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>100 %</span></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
)}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ══ ONGLET VISION MENSUELLE ══ */}
|
||||
{activeTab === 'vision-mensuelle' && (
|
||||
<div style={{ padding: '0 24px' }}>
|
||||
<div className="card">
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
|
||||
<h3 style={{ margin: 0 }}>Vision mensuelle — {effectiveVmYear}</h3>
|
||||
<select value={effectiveVmYear} onChange={e => setVmYear(e.target.value)}
|
||||
style={{ fontSize: 'var(--fs-xs)', padding: '4px 8px', height: 30, borderRadius: 6, border: '1px solid var(--border)', background: 'var(--surface-2)', color: 'var(--text-muted)', cursor: 'pointer', outline: 'none' }}>
|
||||
{(years.length ? years : [effectiveVmYear]).map(y => <option key={y} value={y}>{y}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Plateforme</th>
|
||||
{MOIS_ABBR.map(m => <th key={m} className="num">{m}</th>)}
|
||||
<th className="num">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{vmData.length === 0 && (
|
||||
<tr><td colSpan={14} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>
|
||||
{loading ? 'Chargement…' : `Aucun remboursement en ${effectiveVmYear}`}
|
||||
</td></tr>
|
||||
)}
|
||||
{vmData.map(p => (
|
||||
<tr key={p.plateforme_id}>
|
||||
<td>{p.nom}</td>
|
||||
{p.months.map((v, i) => <td key={i} className="num">{v > 0 ? fmtEUR(v) : '—'}</td>)}
|
||||
<td className="num" style={{ fontWeight: 600 }}>{fmtEUR(p.total)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
{vmData.length > 1 && (
|
||||
<tfoot>
|
||||
<tr style={{ borderTop: '2px solid var(--border)', fontWeight: 700, background: 'var(--surface-2)' }}>
|
||||
<td>Total</td>
|
||||
{vmMonthTotals.map((v, i) => <td key={i} className="num">{v > 0 ? fmtEUR(v) : '—'}</td>)}
|
||||
<td className="num">{fmtEUR(vmGrandTotal)}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
)}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ══ ONGLET REMBOURSEMENTS ══ */}
|
||||
{activeTab === 'remboursements' && (
|
||||
<div style={{ padding: '0 24px' }}>
|
||||
<div className="card">
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
|
||||
<h3 style={{ margin: 0 }}>Remboursements</h3>
|
||||
<ExportDropdown
|
||||
disabled={tableRows.length === 0}
|
||||
onCSV={() => dlBlob(rembPeToCSV(tableRows, dealNameById), 'remboursements.csv', 'text/csv;charset=utf-8')}
|
||||
onXLS={() => dlBlob(rembPeToXLS(tableRows, dealNameById), 'remboursements.xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')}
|
||||
onJSON={() => dlBlob(rembPeToJSON(tableRows, dealNameById), 'remboursements.json', 'application/json')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="row" style={{ marginBottom: 12 }}>
|
||||
<div>
|
||||
<label>Plateforme</label>
|
||||
<select value={tableFilter.plateforme_id} onChange={e => setTableFilter({ ...tableFilter, plateforme_id: e.target.value })}>
|
||||
<option value="">Toutes</option>
|
||||
{plats.map(p => <option key={p.id} value={p.id}>{p.nom}{multiDetenteur && p.investisseur_nom ? ` — ${p.investisseur_nom}` : ''}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>Type</label>
|
||||
<select value={tableFilter.type} onChange={e => setTableFilter({ ...tableFilter, type: e.target.value })}>
|
||||
<option value="">Tous</option>
|
||||
<option value="bonus_plateforme">Bonus plateforme</option>
|
||||
<option value="bonus_parrainage">Bonus parrainage</option>
|
||||
<option value="interets_plateforme">Intérêts plateforme</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>Investissement</label>
|
||||
<select value={tableFilter.investissement_pe_id} onChange={e => setTableFilter({ ...tableFilter, investissement_pe_id: e.target.value })}>
|
||||
<option value="">Tous</option>
|
||||
{deals.map(d => <option key={d.id} value={d.id}>{d.nom_deal}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>Année</label>
|
||||
<select value={tableFilter.year} onChange={e => setTableFilter({ ...tableFilter, year: e.target.value })}>
|
||||
<option value="">Toutes</option>
|
||||
{years.map(y => <option key={y} value={y}>{y}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>Mois</label>
|
||||
<select value={tableFilter.month} onChange={e => setTableFilter({ ...tableFilter, month: e.target.value })}>
|
||||
<option value="">Tous</option>
|
||||
{MOIS_FR.map((m, i) => <option key={i + 1} value={String(i + 1).padStart(2, '0')}>{m}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th><th>Plateforme</th>{multiDetenteur && <th>Détenteur</th>}
|
||||
<th>Type</th><th className="num">Net perçu</th><th>Statut</th><th style={{ width: 40 }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pagedTableRows.length === 0 && (
|
||||
<tr><td colSpan={multiDetenteur ? 7 : 6} className="text-muted" style={{ textAlign: 'center' }}>Aucun mouvement</td></tr>
|
||||
)}
|
||||
{pagedTableRows.map(r => (
|
||||
<tr key={r.id} className="dr-row" style={{ cursor: 'pointer' }} onClick={() => openEdit(r)}>
|
||||
<td>{fmtDate(r.date_remb)}</td>
|
||||
<td>{r.plateforme_nom}</td>
|
||||
{multiDetenteur && <td className="text-muted">{r.plateforme_detenteur_nom || '—'}</td>}
|
||||
<td>{TYPE_LABELS[r.type] || r.type}</td>
|
||||
<td className="num">{fmtEUR(r.net_recu)}</td>
|
||||
<td className="text-muted">{statutLabel(r.statut)}</td>
|
||||
<td onClick={e => e.stopPropagation()}>
|
||||
<button className="icon-btn" title="Supprimer" onClick={() => setDeleteTarget(r)}>✕</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<Pagination page={rembPage} setPage={setRembPage} pageSize={rembPageSize} setPageSize={setRembPageSize}
|
||||
totalPages={rembTotalPages} totalItems={rembTotalItems} PAGE_SIZES={PAGE_SIZES} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
open={modalOpen}
|
||||
title={editingId ? 'Modifier le remboursement' : 'Nouveau remboursement'}
|
||||
onClose={close}
|
||||
busy={submitting}
|
||||
footer={
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', width: '100%' }}>
|
||||
<div>
|
||||
{editingId && (
|
||||
<button className="danger" disabled={submitting}
|
||||
onClick={() => { const target = { id: editingId }; close(); setDeleteTarget(target); }}>
|
||||
Supprimer
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button disabled={submitting} onClick={close}>Annuler</button>
|
||||
<button className="primary" disabled={submitting} onClick={() => runSubmit(submit)}>
|
||||
{submitting ? (editingId ? 'Enregistrement…' : 'Création…') : (editingId ? 'Enregistrer' : 'Créer')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<RemboursementForm form={form} setForm={setForm} plats={plats} investisseurs={investisseurs} deals={deals}
|
||||
multiDetenteur={multiDetenteur} err={err} runSubmit={runSubmit} submit={submit}
|
||||
investisseurForPlat={investisseurForPlat} editingId={editingId} />
|
||||
</Modal>
|
||||
|
||||
<ConfirmModal
|
||||
open={!!deleteTarget}
|
||||
title="Supprimer ce remboursement ?"
|
||||
message="Cette action est irréversible."
|
||||
confirmLabel="Supprimer"
|
||||
onConfirm={onDelete}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Formulaire d'ajout/édition — factorisé pour être identique dans les deux
|
||||
* points d'entrée (page avec mouvements existants, et état vide). Champs
|
||||
* strictement alignés sur le Schema de backend/src/routes/remboursements.js,
|
||||
* limités aux types bonus_parrainage / bonus_plateforme / interets_plateforme
|
||||
* (le type "normal", lié à un investissement, reste crowdlending uniquement). */
|
||||
function RemboursementForm({ form, setForm, plats, investisseurs, deals, multiDetenteur, err, runSubmit, submit, investisseurForPlat, editingId }) {
|
||||
const isInterets = form.type === 'interets_plateforme';
|
||||
|
||||
// Deals PE de la plateforme sélectionnée (un remboursement bonus/intérêts
|
||||
// peut être rattaché à un deal précis, pour le calcul de son TVPI — cf.
|
||||
// pages/InvestissementsPe.jsx). Optionnel : plusieurs plateformes n'ont
|
||||
// qu'un seul deal, ou aucun deal PE suivi pour l'instant.
|
||||
const dealsForPlat = deals.filter(d => String(d.plateforme_id) === String(form.bonus_plateforme_id));
|
||||
|
||||
const previewNet = () => {
|
||||
if (isInterets) {
|
||||
const bruts = Number(form.interets_bruts) || 0;
|
||||
const frais = Number(form.frais_ttc) || 0;
|
||||
const ps = Number(form.prelev_sociaux) || 0;
|
||||
const pf = Number(form.prelev_forfaitaire) || 0;
|
||||
return Math.round((bruts - frais - ps - pf) * 100) / 100;
|
||||
}
|
||||
return Number(form.cashback) || 0;
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={e => runSubmit(submit, e)}>
|
||||
{err && <div className="error">{err}</div>}
|
||||
<div className="form-grid">
|
||||
<div>
|
||||
<label>Type *</label>
|
||||
<select required value={form.type} disabled={!!editingId}
|
||||
title={editingId ? 'Le type ne peut pas être modifié après création' : undefined}
|
||||
onChange={e => setForm({ ...form, type: e.target.value })}>
|
||||
<option value="bonus_plateforme">Bonus plateforme</option>
|
||||
<option value="bonus_parrainage">Bonus parrainage</option>
|
||||
<option value="interets_plateforme">Intérêts plateforme</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>Plateforme *</label>
|
||||
<select required value={form.bonus_plateforme_id} onChange={e => {
|
||||
const platId = e.target.value;
|
||||
setForm(f => ({ ...f, bonus_plateforme_id: platId, bonus_investisseur_id: investisseurForPlat(platId), investissement_pe_id: '' }));
|
||||
}}>
|
||||
<option value="">—</option>
|
||||
{plats.map(p => <option key={p.id} value={p.id}>{p.nom}{multiDetenteur && p.investisseur_nom ? ` — ${p.investisseur_nom}` : ''}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
{dealsForPlat.length > 0 && (
|
||||
<div>
|
||||
<label>Investissement (optionnel)</label>
|
||||
<select value={form.investissement_pe_id} onChange={e => setForm({ ...form, investissement_pe_id: e.target.value })}>
|
||||
<option value="">— Aucun (non rattaché à un deal) —</option>
|
||||
{dealsForPlat.map(d => <option key={d.id} value={d.id}>{d.nom_deal}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label>Date *</label>
|
||||
<input type="date" required value={form.date_remb} onChange={e => setForm({ ...form, date_remb: e.target.value })} />
|
||||
</div>
|
||||
|
||||
{!isInterets && (
|
||||
<div>
|
||||
<label>Montant reçu (€) *</label>
|
||||
<input type="number" step="0.01" min="0" required value={form.cashback} onChange={e => setForm({ ...form, cashback: e.target.value })} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isInterets && (
|
||||
<>
|
||||
<div>
|
||||
<label>Intérêts bruts (€) *</label>
|
||||
<input type="number" step="0.01" min="0" required value={form.interets_bruts} onChange={e => setForm({ ...form, interets_bruts: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<label>Intérêts bruts avant taxe locale (€)</label>
|
||||
<input type="number" step="0.01" min="0" value={form.interets_bruts_avant_local} onChange={e => setForm({ ...form, interets_bruts_avant_local: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<label>Taxe locale (€)</label>
|
||||
<input type="number" step="0.01" min="0" value={form.taxe_locale} onChange={e => setForm({ ...form, taxe_locale: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<label>Frais TTC (€)</label>
|
||||
<input type="number" step="0.01" min="0" value={form.frais_ttc} onChange={e => setForm({ ...form, frais_ttc: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<label>Prélèvements sociaux (€)</label>
|
||||
<input type="number" step="0.01" min="0" value={form.prelev_sociaux} onChange={e => setForm({ ...form, prelev_sociaux: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<label>Prélèvement forfaitaire (€)</label>
|
||||
<input type="number" step="0.01" min="0" value={form.prelev_forfaitaire} onChange={e => setForm({ ...form, prelev_forfaitaire: e.target.value })} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label>Statut</label>
|
||||
<select value={form.statut} onChange={e => setForm({ ...form, statut: e.target.value })}>
|
||||
<option value="paye">Payé</option>
|
||||
<option value="retard">En retard</option>
|
||||
<option value="partiel">Partiel</option>
|
||||
<option value="impaye">Impayé</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style={{ gridColumn: '1 / -1' }}>
|
||||
<label>Détenteur *</label>
|
||||
<select required value={form.bonus_investisseur_id} onChange={e => setForm({ ...form, bonus_investisseur_id: e.target.value })}>
|
||||
<option value="">—</option>
|
||||
{investisseurs.filter(i => i.type !== 'entreprise').length > 0 && (
|
||||
<optgroup label="Famille">
|
||||
{investisseurs.filter(i => i.type !== 'entreprise').map(i => (
|
||||
<option key={i.id} value={i.id}>{i.nom}{i.is_principal ? ' (principal)' : ''}</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
{investisseurs.filter(i => i.type === 'entreprise').length > 0 && (
|
||||
<optgroup label="Entreprises">
|
||||
{investisseurs.filter(i => i.type === 'entreprise').map(i => (
|
||||
<option key={i.id} value={i.id}>{i.nom}</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
<div style={{ gridColumn: '1 / -1' }}>
|
||||
<label>Notes</label>
|
||||
<input value={form.notes} onChange={e => setForm({ ...form, notes: e.target.value })} />
|
||||
</div>
|
||||
<div style={{ gridColumn: '1 / -1', color: 'var(--text-muted, #6b7280)', fontSize: 13 }}>
|
||||
Net perçu estimé : {fmtEUR(previewNet())}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { useUi } from '../../context/UiContext.jsx';
|
||||
import { api } from '../../api.js';
|
||||
import { getSuggestedPalettes } from '../../utils/chartPalettes.js';
|
||||
import { COLOR_THEMES, colorThemeSwatch } from '../../utils/colorThemes.js';
|
||||
import { useWorkspace } from '../../context/WorkspaceContext.jsx';
|
||||
import ThemeSelect from '../../components/ThemeSelect.jsx';
|
||||
|
||||
|
||||
@@ -281,6 +282,7 @@ function ChartColorPicker({ value, onChange }) {
|
||||
export default function AppearanceSection() {
|
||||
const { mode, setMode } = useTheme();
|
||||
const { fontScale, setFontScale, chartInterets, setChartInterets, chartCapital, setChartCapital, chartCashback, setChartCashback, colorTheme, adminColorTheme, setColorTheme } = useUi();
|
||||
const { activeWorkspaces, hasMultipleWorkspaces, setWorkspaceColor } = useWorkspace() || {};
|
||||
const [libIcons, setLibIcons] = useState({});
|
||||
const suggestedPalettes = useMemo(() => getSuggestedPalettes(), []);
|
||||
// "Automatique" (value '') suit le thème par défaut de l'administrateur —
|
||||
@@ -335,6 +337,40 @@ export default function AppearanceSection() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Couleur par espace de travail (17/09/26) : visible seulement si
|
||||
plusieurs espaces sont actifs (rien à distinguer sinon). Par
|
||||
défaut chaque espace hérite de la couleur de l'application
|
||||
ci-dessus ; l'utilisateur peut la personnaliser par espace, et
|
||||
elle s'applique automatiquement en basculant dessus (cf.
|
||||
Layout.jsx / UiContext.jsx workspaceColorOverride). */}
|
||||
{hasMultipleWorkspaces && (
|
||||
<div style={{ marginTop: 20, paddingTop: 20, borderTop: '1px solid var(--border)' }}>
|
||||
<div style={{ fontWeight: 700, fontSize: 'var(--fs-sm)' }}>Couleur des espaces de travail</div>
|
||||
<div style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', marginTop: 2, marginBottom: 14, maxWidth: 420 }}>
|
||||
Attribuez une couleur à chaque espace : elle remplace automatiquement la couleur
|
||||
de l'application quand vous basculez dessus. Par défaut, un espace hérite de la
|
||||
couleur de l'application ci-dessus.
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{activeWorkspaces.map((w) => (
|
||||
<div key={w.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 16, flexWrap: 'wrap' }}>
|
||||
<span style={{ fontSize: 'var(--fs-sm)' }}>{w.libelle_menu || w.nom}</span>
|
||||
<div style={{ width: 260, flexShrink: 0 }}>
|
||||
<ThemeSelect
|
||||
value={w.color_theme || ''}
|
||||
onChange={(v) => setWorkspaceColor(w.id, v || null)}
|
||||
options={[
|
||||
{ value: '', label: "Hérite de la couleur de l'application", swatch: colorThemeSwatch(colorTheme || adminColorTheme) },
|
||||
...COLOR_THEMES,
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
|
||||
@@ -3,6 +3,7 @@ import { api } from '../../api.js';
|
||||
import { memberLabel } from '../../utils/format.js';
|
||||
import ConfirmModal from '../../components/ConfirmModal.jsx';
|
||||
import Modal from '../../components/Modal.jsx';
|
||||
import { useAsyncAction } from '../../hooks/useAsyncAction.js';
|
||||
|
||||
const EXONERATION_LABELS = {
|
||||
aucune: 'Aucune',
|
||||
@@ -85,6 +86,8 @@ export default function ComptesSection() {
|
||||
const [editCompte, setEditCompte] = useState(null);
|
||||
const [err, setErr] = useState(null);
|
||||
const [confirmDel, setConfirmDel] = useState(null);
|
||||
const { run: runAddCompte, pending: addingCompte } = useAsyncAction();
|
||||
const { run: runSaveEdit, pending: savingEdit } = useAsyncAction();
|
||||
|
||||
const openNew = () => { setNewCompte(EMPTY_COMPTE); setErr(null); setShowNew(true); };
|
||||
|
||||
@@ -204,14 +207,15 @@ export default function ComptesSection() {
|
||||
open={showNew}
|
||||
title="Nouveau compte"
|
||||
onClose={() => { setShowNew(false); setNewCompte(EMPTY_COMPTE); setErr(null); }}
|
||||
busy={addingCompte}
|
||||
footer={
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', width: '100%', gap: 8 }}>
|
||||
<button type="button" onClick={() => { setShowNew(false); setNewCompte(EMPTY_COMPTE); setErr(null); }}>Annuler</button>
|
||||
<button className="primary" form="form-new-compte" type="submit">Créer</button>
|
||||
<button type="button" disabled={addingCompte} onClick={() => { setShowNew(false); setNewCompte(EMPTY_COMPTE); setErr(null); }}>Annuler</button>
|
||||
<button className="primary" form="form-new-compte" type="submit" disabled={addingCompte}>{addingCompte ? 'Création…' : 'Créer'}</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<form id="form-new-compte" onSubmit={addCompte} style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<form id="form-new-compte" onSubmit={e => runAddCompte(addCompte, e)} style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{err && <div className="error">{err}</div>}
|
||||
<CompteFormFields state={newCompte} setter={setNewCompte} investisseurs={investisseurs} />
|
||||
</form>
|
||||
@@ -222,19 +226,20 @@ export default function ComptesSection() {
|
||||
open={!!editCompte}
|
||||
title="Modifier le compte"
|
||||
onClose={() => { setEditCompte(null); setErr(null); }}
|
||||
busy={savingEdit}
|
||||
footer={
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', width: '100%' }}>
|
||||
<button className="danger" type="button" onClick={() => { setEditCompte(null); del(comptes.find(c => c.id === editCompte?.id) ?? editCompte); }}>
|
||||
<button className="danger" type="button" disabled={savingEdit} onClick={() => { setEditCompte(null); del(comptes.find(c => c.id === editCompte?.id) ?? editCompte); }}>
|
||||
Supprimer
|
||||
</button>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button type="button" onClick={() => { setEditCompte(null); setErr(null); }}>Annuler</button>
|
||||
<button className="primary" form="form-edit-compte" type="submit">Enregistrer</button>
|
||||
<button type="button" disabled={savingEdit} onClick={() => { setEditCompte(null); setErr(null); }}>Annuler</button>
|
||||
<button className="primary" form="form-edit-compte" type="submit" disabled={savingEdit}>{savingEdit ? 'Enregistrement…' : 'Enregistrer'}</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<form id="form-edit-compte" onSubmit={saveEdit} style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<form id="form-edit-compte" onSubmit={e => runSaveEdit(saveEdit, e)} style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{err && <div className="error">{err}</div>}
|
||||
<CompteFormFields state={editCompte} setter={setEditCompte} investisseurs={investisseurs} />
|
||||
</form>
|
||||
|
||||
@@ -8,7 +8,9 @@ import ConfirmModal from '../../components/ConfirmModal.jsx';
|
||||
import CountrySelect, { COUNTRIES, FlagIcon } from '../../components/CountrySelect.jsx';
|
||||
import ResultBanner from '../../components/ResultBanner.jsx';
|
||||
import { useInvestisseur } from '../../context/InvestisseurContext.jsx';
|
||||
import { useWorkspace } from '../../context/WorkspaceContext.jsx';
|
||||
import { memberLabel, fmtDate } from '../../utils/format.js';
|
||||
import { useAsyncAction } from '../../hooks/useAsyncAction.js';
|
||||
|
||||
function dlBlob(content, filename, type) {
|
||||
const blob = new Blob([content], { type });
|
||||
@@ -54,7 +56,7 @@ function fmtFiscalite(p) {
|
||||
return base;
|
||||
}
|
||||
|
||||
const EMPTY_PLAT = { nom: '', url: '', domiciliation: 'france', fiscalite: 'flat_tax', taux_fiscalite_locale: '', type_produit_fiscal: '2TT', categorie_plateforme: 'psfp', frais_plateforme: 'aucun', methode_remboursement: 'portefeuille', investisseur_id: null, date_ouverture: '', logo_filename: null, type_pret_defaut: '', freq_interets_defaut: '', referentiel_id: null };
|
||||
const EMPTY_PLAT = { nom: '', url: '', domiciliation: 'france', fiscalite: 'flat_tax', taux_fiscalite_locale: '', type_produit_fiscal: '2TT', categorie_plateforme: 'psfp', frais_plateforme: 'aucun', methode_remboursement: 'portefeuille', investisseur_id: null, date_ouverture: '', logo_filename: null, type_pret_defaut: '', freq_interets_defaut: '', referentiel_id: null, workspace_ids: [] };
|
||||
|
||||
const LOGO_BASE = (import.meta.env.VITE_API_URL || '/api').replace(/\/api$/, '') + '/api/logos/';
|
||||
const logoUrl = (filename) => filename ? LOGO_BASE + filename : null;
|
||||
@@ -230,6 +232,12 @@ function PlatDetailPanel({ plat, onEdit }) {
|
||||
label: "Secteurs d'investissement",
|
||||
value: <div style={{ display:'flex', flexWrap:'wrap', gap:4 }}>{platSectsInv.map(s => <span key={s.id} className="chip-sect">{s.nom}</span>)}</div>,
|
||||
},
|
||||
{
|
||||
label: 'Espaces de travail',
|
||||
value: (plat.workspaces || []).length > 0
|
||||
? <div style={{ display:'flex', flexWrap:'wrap', gap:4 }}>{plat.workspaces.map(w => <span key={w.id} className="chip-workspace">{w.nom}</span>)}</div>
|
||||
: '—',
|
||||
},
|
||||
plat.type_pret_defaut && { label: 'Type de prêt (défaut)', value: { in_fine: 'In fine', amortissable: 'Amortissable', differe: 'Différé' }[plat.type_pret_defaut] ?? plat.type_pret_defaut },
|
||||
plat.freq_interets_defaut && { label: 'Périodicité (défaut)', value: { mensuel: 'Mensuelle', trimestriel: 'Trimestrielle', in_fine: 'In fine' }[plat.freq_interets_defaut] ?? plat.freq_interets_defaut },
|
||||
{
|
||||
@@ -290,20 +298,25 @@ function PlatDetailPanel({ plat, onEdit }) {
|
||||
|
||||
|
||||
/* ── Picker référentiel ──────────────────────────────────────── */
|
||||
function PlatPickerModal({ open, onClose, referentiel, onSelect, onManual, err }) {
|
||||
function PlatPickerModal({ open, onClose, referentiel, onSelect, onManual, err, activeWorkspaces = [] }) {
|
||||
const [search, setSearch] = useState('');
|
||||
const [domFilter, setDomFilter] = useState('all'); // 'all' | 'fr' | 'etr'
|
||||
const [confirming, setConfirming] = useState(null); // ref en cours de confirmation
|
||||
const [dateOuverture, setDateOuverture] = useState('');
|
||||
const [chosenWorkspace, setChosenWorkspace] = useState(null); // workspace choisi (si l'utilisateur en a plusieurs)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) { setConfirming(null); setDateOuverture(''); }
|
||||
}, [open]);
|
||||
if (open) {
|
||||
setConfirming(null); setDateOuverture('');
|
||||
setChosenWorkspace(activeWorkspaces.length === 1 ? activeWorkspaces[0].id : null);
|
||||
}
|
||||
}, [open]); // eslint-disable-line
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const q = search.trim().toLowerCase();
|
||||
const filtered = referentiel.filter(r => {
|
||||
if (chosenWorkspace && !(r.workspaces || []).some(w => w.id === chosenWorkspace)) return false;
|
||||
if (domFilter === 'fr' && r.domiciliation !== 'FR') return false;
|
||||
if (domFilter === 'etr' && r.domiciliation === 'FR') return false;
|
||||
if (q && !r.nom.toLowerCase().includes(q)) return false;
|
||||
@@ -315,6 +328,34 @@ function PlatPickerModal({ open, onClose, referentiel, onSelect, onManual, err }
|
||||
return f ? (LOGO_BASE + f) : null;
|
||||
};
|
||||
|
||||
/* ── Étape de choix de l'espace de travail (si plusieurs disponibles) ── */
|
||||
if (activeWorkspaces.length > 1 && chosenWorkspace === null) {
|
||||
return (
|
||||
<Modal open={open} title="Ajouter une plateforme" onClose={onClose} width={480}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<p style={{ margin: 0, color: 'var(--text-muted)', fontSize: 'var(--fs-sm)', lineHeight: 1.5 }}>
|
||||
Sur quel espace de travail voulez-vous ajouter cette plateforme ? Seules les plateformes
|
||||
du référentiel éligibles à cet espace vous seront proposées.
|
||||
</p>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{activeWorkspaces.map(w => (
|
||||
<button key={w.id} type="button" onClick={() => setChosenWorkspace(w.id)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '12px 16px', borderRadius: 8, border: '1.5px solid var(--border)',
|
||||
background: 'var(--surface)', cursor: 'pointer', textAlign: 'left',
|
||||
fontSize: 'var(--fs-sm)', fontWeight: 600, color: 'var(--text)',
|
||||
}}>
|
||||
{w.libelle_menu || w.nom}
|
||||
<span style={{ opacity: 0.5 }}>→</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Étape de confirmation : date d'ouverture du compte ── */
|
||||
if (confirming) {
|
||||
const img = imgFor(confirming);
|
||||
@@ -327,7 +368,7 @@ function PlatPickerModal({ open, onClose, referentiel, onSelect, onManual, err }
|
||||
footer={
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', width: '100%' }}>
|
||||
<button type="button" className="ghost" onClick={() => setConfirming(null)}>← Retour</button>
|
||||
<button type="button" className="primary" onClick={() => onSelect(confirming, dateOuverture)}>
|
||||
<button type="button" className="primary" onClick={() => onSelect(confirming, dateOuverture, chosenWorkspace)}>
|
||||
Ajouter
|
||||
</button>
|
||||
</div>
|
||||
@@ -384,6 +425,15 @@ function PlatPickerModal({ open, onClose, referentiel, onSelect, onManual, err }
|
||||
Les informations (domiciliation, fiscalité…) seront pré-remplies automatiquement.
|
||||
</p>
|
||||
|
||||
{activeWorkspaces.length > 1 && (
|
||||
<button type="button" onClick={() => setChosenWorkspace(null)}
|
||||
style={{ alignSelf: 'flex-start', background: 'none', border: 'none', padding: 0,
|
||||
color: 'var(--primary)', fontSize: 12, fontWeight: 600, cursor: 'pointer' }}>
|
||||
← Espace de travail : {activeWorkspaces.find(w => w.id === chosenWorkspace)?.libelle_menu
|
||||
|| activeWorkspaces.find(w => w.id === chosenWorkspace)?.nom || ''} (changer)
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Barre de recherche + chips filtre */}
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<input
|
||||
@@ -494,7 +544,7 @@ function PlatPickerModal({ open, onClose, referentiel, onSelect, onManual, err }
|
||||
|
||||
/* ── Formulaire plateforme partagé (création + édition) ─────────── */
|
||||
function platFormSteps({ state, setter, logoFile, setLogoFile, logoPreview, setLogoPreview,
|
||||
investisseurs, referentiel, categoriesInv, secteursInv, isEdit = false, onDelLogo }) {
|
||||
investisseurs, referentiel, categoriesInv, secteursInv, isEdit = false, onDelLogo, activeWorkspaces = [] }) {
|
||||
|
||||
const isFrance = state.domiciliation === 'FR';
|
||||
|
||||
@@ -575,6 +625,27 @@ function platFormSteps({ state, setter, logoFile, setLogoFile, logoPreview, setL
|
||||
<input type="date" value={state.date_ouverture || ''} onChange={e => setter(s => ({ ...s, date_ouverture: e.target.value }))}
|
||||
style={{ width: '100%', boxSizing: 'border-box' }} />
|
||||
))}
|
||||
|
||||
{/* Espaces de travail (uniquement affiché si plusieurs disponibles) */}
|
||||
{activeWorkspaces.length > 1 && row('Espaces de travail *', (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||
{activeWorkspaces.map(w => {
|
||||
const checked = (state.workspace_ids || []).includes(w.id);
|
||||
return (
|
||||
<label key={w.id} style={{ display: 'flex', alignItems: 'center', gap: 4, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={checked} style={{ width: 'auto' }}
|
||||
onChange={e => setter(s => ({
|
||||
...s,
|
||||
workspace_ids: e.target.checked
|
||||
? [...(s.workspace_ids || []), w.id]
|
||||
: (s.workspace_ids || []).filter(id => id !== w.id),
|
||||
}))} />
|
||||
<span className="chip-workspace" style={{ fontSize: 11 }}>{w.libelle_menu || w.nom}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
), "Cette plateforme ne sera visible/utilisable que dans le(s) espace(s) de travail coché(s).")}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -741,6 +812,7 @@ function platFormSteps({ state, setter, logoFile, setLogoFile, logoPreview, setL
|
||||
|
||||
export default function PlateformesSection() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { activeWorkspaces } = useWorkspace();
|
||||
|
||||
// ── State ──────────────────────────────────────────────────────
|
||||
const [plats, setPlats] = useState([]);
|
||||
@@ -779,6 +851,8 @@ export default function PlateformesSection() {
|
||||
|
||||
|
||||
const [err, setErr] = useState(null);
|
||||
const { run: runAddPlat, pending: addingPlat } = useAsyncAction();
|
||||
const { run: runSaveEditPlat, pending: savingEditPlat } = useAsyncAction();
|
||||
const [msg, setMsg] = useState(null);
|
||||
const [confirmDelete, setConfirmDelete] = useState(null);
|
||||
const platImportRef = useRef(null);
|
||||
@@ -886,8 +960,9 @@ export default function PlateformesSection() {
|
||||
};
|
||||
|
||||
/* ── Ajouter depuis le référentiel ──────────────────────────── */
|
||||
const addPlatFromRef = async (ref, dateOuverture) => {
|
||||
const addPlatFromRef = async (ref, dateOuverture, workspaceId) => {
|
||||
try {
|
||||
const wsId = workspaceId ?? activeWorkspaces[0]?.id;
|
||||
await api.post('/plateformes', {
|
||||
nom: ref.nom,
|
||||
url: ref.url || '',
|
||||
@@ -902,6 +977,7 @@ export default function PlateformesSection() {
|
||||
freq_interets_defaut: ref.freq_interets_defaut || null,
|
||||
referentiel_id: ref.id,
|
||||
date_ouverture: dateOuverture || null,
|
||||
workspace_ids: wsId ? [wsId] : [],
|
||||
});
|
||||
setShowAddPicker(false);
|
||||
await load();
|
||||
@@ -911,6 +987,10 @@ export default function PlateformesSection() {
|
||||
/* ── Plateformes ─────────────────────────────────────────────── */
|
||||
const addPlat = async (e) => {
|
||||
e.preventDefault(); setErr(null); setMsg(null);
|
||||
if (activeWorkspaces.length > 1 && (!newPlat.workspace_ids || newPlat.workspace_ids.length === 0)) {
|
||||
setErr('Sélectionnez au moins un espace de travail.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const created = await api.post('/plateformes', {
|
||||
...newPlat,
|
||||
@@ -922,6 +1002,9 @@ export default function PlateformesSection() {
|
||||
type_pret_defaut: newPlat.type_pret_defaut || null,
|
||||
freq_interets_defaut: newPlat.freq_interets_defaut || null,
|
||||
referentiel_id: newPlat.referentiel_id ? Number(newPlat.referentiel_id) : null,
|
||||
workspace_ids: (newPlat.workspace_ids && newPlat.workspace_ids.length > 0)
|
||||
? newPlat.workspace_ids
|
||||
: (activeWorkspaces[0] ? [activeWorkspaces[0].id] : []),
|
||||
});
|
||||
if (newPlatLogoFile) await uploadLogo(created.id, newPlatLogoFile);
|
||||
setNewPlat(EMPTY_PLAT);
|
||||
@@ -997,6 +1080,7 @@ export default function PlateformesSection() {
|
||||
secteurs_inv_ids: platSectsInv.map(s => s.id),
|
||||
inherited_cat_ids: platCatsInv.filter(c => c.is_inherited).map(c => c.id),
|
||||
inherited_sect_ids: platSectsInv.filter(s => s.is_inherited).map(s => s.id),
|
||||
workspace_ids: (p.workspaces || []).map(w => w.id),
|
||||
domiciliation: p.domiciliation || 'france',
|
||||
fiscalite: p.fiscalite || 'flat_tax',
|
||||
taux_fiscalite_locale: p.taux_fiscalite_locale ?? '',
|
||||
@@ -1017,6 +1101,10 @@ export default function PlateformesSection() {
|
||||
|
||||
const saveEditPlat = async (e) => {
|
||||
e.preventDefault(); setErr(null); setMsg(null);
|
||||
if (activeWorkspaces.length > 1 && (!editPlat.workspace_ids || editPlat.workspace_ids.length === 0)) {
|
||||
setErr('Sélectionnez au moins un espace de travail.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.put(`/plateformes/${editPlat.id}`, {
|
||||
nom: editPlat.nom, url: editPlat.url || '',
|
||||
@@ -1032,6 +1120,9 @@ export default function PlateformesSection() {
|
||||
date_ouverture: editPlat.date_ouverture || null,
|
||||
type_pret_defaut: editPlat.type_pret_defaut || null,
|
||||
freq_interets_defaut: editPlat.freq_interets_defaut || null,
|
||||
workspace_ids: (editPlat.workspace_ids && editPlat.workspace_ids.length > 0)
|
||||
? editPlat.workspace_ids
|
||||
: (activeWorkspaces[0] ? [activeWorkspaces[0].id] : []),
|
||||
});
|
||||
if (editPlatLogoFile) await uploadLogo(editPlat.id, editPlatLogoFile);
|
||||
// Sauvegarder les associations catégories/secteurs d'investissement
|
||||
@@ -1114,8 +1205,7 @@ export default function PlateformesSection() {
|
||||
<th style={{ padding: '10px 8px', width: 40 }}></th>
|
||||
<th style={{ padding: '10px 16px', textAlign: 'left' }}>Nom</th>
|
||||
<th style={{ padding: '10px 8px', textAlign: 'left' }}>Domiciliation</th>
|
||||
<th style={{ padding: '10px 8px', textAlign: 'left' }}>Catégories</th>
|
||||
<th style={{ padding: '10px 8px', textAlign: 'left' }}>Secteurs</th>
|
||||
<th style={{ padding: '10px 8px', textAlign: 'left' }}>Workspaces</th>
|
||||
{multiDetenteur && <th style={{ padding: '10px 8px', textAlign: 'left' }}>Détenteur</th>}
|
||||
<th style={{ padding: '10px 16px', textAlign: 'center', width: 60 }}>Invest.</th>
|
||||
<th style={{ padding: '10px 8px', width: 40 }}></th>
|
||||
@@ -1123,13 +1213,12 @@ export default function PlateformesSection() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{plats.length === 0 && (
|
||||
<tr><td colSpan={multiDetenteur ? 8 : 7} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>Aucune plateforme</td></tr>
|
||||
<tr><td colSpan={multiDetenteur ? 7 : 6} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>Aucune plateforme</td></tr>
|
||||
)}
|
||||
{plats.map((p, i) => {
|
||||
const imgSrc = p.icone_filename ? logoUrl(p.icone_filename) : p.logo_filename ? logoUrl(p.logo_filename) : null;
|
||||
const inv = platInvestisseur(p);
|
||||
const cats = p.categories_inv || [];
|
||||
const sects = p.secteurs_inv || [];
|
||||
const wsList = p.workspaces || [];
|
||||
return (
|
||||
<tr key={p.id}
|
||||
onClick={() => setSelectedPlat(selectedPlat?.id === p.id ? null : p)}
|
||||
@@ -1158,16 +1247,9 @@ export default function PlateformesSection() {
|
||||
</td>
|
||||
<td style={{ padding: '10px 8px' }}>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
|
||||
{cats.slice(0, 2).map(c => <span key={c.id} className="chip-cat" style={{ fontSize: 11 }}>{c.nom}</span>)}
|
||||
{cats.length > 2 && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>+{cats.length - 2}</span>}
|
||||
{cats.length === 0 && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>—</span>}
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: '10px 8px' }}>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
|
||||
{sects.slice(0, 2).map(s => <span key={s.id} className="chip-sect" style={{ fontSize: 11 }}>{s.nom}</span>)}
|
||||
{sects.length > 2 && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>+{sects.length - 2}</span>}
|
||||
{sects.length === 0 && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>—</span>}
|
||||
{wsList.slice(0, 2).map(w => <span key={w.id} className="chip-workspace" style={{ fontSize: 11 }}>{w.nom}</span>)}
|
||||
{wsList.length > 2 && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>+{wsList.length - 2}</span>}
|
||||
{wsList.length === 0 && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>—</span>}
|
||||
</div>
|
||||
</td>
|
||||
{multiDetenteur && (
|
||||
@@ -1245,6 +1327,7 @@ export default function PlateformesSection() {
|
||||
onSelect={addPlatFromRef}
|
||||
onManual={() => { setNewPlat(EMPTY_PLAT); setErr(null); setShowNewPlat(true); }}
|
||||
err={err}
|
||||
activeWorkspaces={activeWorkspaces}
|
||||
/>
|
||||
|
||||
{/* ── Modal : Ajouter une plateforme ── */}
|
||||
@@ -1255,12 +1338,14 @@ export default function PlateformesSection() {
|
||||
logoPreview: newPlatLogoPreview, setLogoPreview: setNewPlatLogoPreview,
|
||||
investisseurs, referentiel,
|
||||
categoriesInv: [], secteursInv: [],
|
||||
activeWorkspaces,
|
||||
});
|
||||
return (
|
||||
<PlateformeFormModal
|
||||
title="Nouvelle plateforme"
|
||||
onClose={() => { setShowNewPlat(false); setNewPlat(EMPTY_PLAT); setNewPlatLogoFile(null); setNewPlatLogoPreview(null); setErr(null); }}
|
||||
onSubmit={addPlat}
|
||||
onSubmit={e => runAddPlat(addPlat, e)}
|
||||
saving={addingPlat}
|
||||
err={err}
|
||||
submitLabel="Créer"
|
||||
width={600}
|
||||
@@ -1279,12 +1364,14 @@ export default function PlateformesSection() {
|
||||
categoriesInv, secteursInv,
|
||||
isEdit: true,
|
||||
onDelLogo: () => delLogoPlat(editPlat.id),
|
||||
activeWorkspaces,
|
||||
});
|
||||
return (
|
||||
<PlateformeFormModal
|
||||
title="Modifier la plateforme"
|
||||
onClose={() => { setEditPlat(null); setEditPlatLogoFile(null); setEditPlatLogoPreview(null); setErr(null); }}
|
||||
onSubmit={saveEditPlat}
|
||||
onSubmit={e => runSaveEditPlat(saveEditPlat, e)}
|
||||
saving={savingEditPlat}
|
||||
err={err}
|
||||
submitLabel="Enregistrer"
|
||||
width={600}
|
||||
|
||||
@@ -1015,6 +1015,11 @@ tr:hover td { background: var(--surface-2); }
|
||||
font-weight: 500; background: rgba(16,185,129,.12); color: var(--success);
|
||||
border: 1px solid rgba(16,185,129,.25);
|
||||
}
|
||||
.chip-workspace {
|
||||
display: inline-block; padding: 2px 10px; border-radius: 20px; font-size: 12px;
|
||||
font-weight: 500; background: rgba(245,158,11,.12); color: #d97706;
|
||||
border: 1px solid rgba(245,158,11,.25);
|
||||
}
|
||||
|
||||
/* ── Filtre par catégorie — bloc "Mes documents" (Mon compte) ── */
|
||||
.doc-cat-tabs { display: flex; gap: 8px; margin-bottom: 16px; flex-wrap: wrap; }
|
||||
|
||||
+15
-8
@@ -152,8 +152,9 @@ export function registerDataTools(server, { apiGet, withLabel = (t) => t, withSo
|
||||
title: withLabel('Liste des remboursements'),
|
||||
description: withSource(
|
||||
"Liste les remboursements réels perçus (toutes plateformes confondues), " +
|
||||
"avec le nom du projet et de la plateforme. Filtrable par période. " +
|
||||
"Chaque ligne inclut frais_ttc (montant de frais saisi sur ce " +
|
||||
"avec le nom du projet et de la plateforme. Filtrable par période et par " +
|
||||
"workspace (paramètre workspace ; sans ce paramètre, tous workspaces " +
|
||||
"confondus). Chaque ligne inclut frais_ttc (montant de frais saisi sur ce " +
|
||||
"remboursement) et frais_mode_reglement : le frais ne réduit " +
|
||||
"interets_nets/net_recu que si frais_mode_reglement vaut " +
|
||||
"\"remboursement\" — avec \"portefeuille\" ou \"compte_courant\", le frais " +
|
||||
@@ -164,11 +165,13 @@ export function registerDataTools(server, { apiGet, withLabel = (t) => t, withSo
|
||||
.describe('Date de début au format YYYY-MM-DD (incluse)'),
|
||||
date_fin: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional()
|
||||
.describe('Date de fin au format YYYY-MM-DD (incluse)'),
|
||||
workspace: z.string().optional()
|
||||
.describe('Slug du workspace à filtrer (ex. "crowdlending"). Absent : tous workspaces confondus.'),
|
||||
},
|
||||
annotations: READ_ONLY_ANNOTATIONS,
|
||||
},
|
||||
async ({ date_debut, date_fin }) => {
|
||||
try { return toolResult(await apiGet('/remboursements', { date_debut, date_fin })); }
|
||||
async ({ date_debut, date_fin, workspace }) => {
|
||||
try { return toolResult(await apiGet('/remboursements', { date_debut, date_fin, workspace })); }
|
||||
catch (e) { return toolError(e); }
|
||||
},
|
||||
);
|
||||
@@ -214,12 +217,16 @@ export function registerDataTools(server, { apiGet, withLabel = (t) => t, withSo
|
||||
title: withLabel('Liste des dépôts / retraits'),
|
||||
description: withSource(
|
||||
"Liste les mouvements de cash (dépôts et retraits) sur les plateformes " +
|
||||
"de crowdlending, du plus récent au plus ancien."),
|
||||
inputSchema: {},
|
||||
"de crowdlending, du plus récent au plus ancien. Filtrable par workspace " +
|
||||
"(paramètre workspace) ; sans ce paramètre, tous workspaces confondus."),
|
||||
inputSchema: {
|
||||
workspace: z.string().optional()
|
||||
.describe('Slug du workspace à filtrer (ex. "crowdlending"). Absent : tous workspaces confondus.'),
|
||||
},
|
||||
annotations: READ_ONLY_ANNOTATIONS,
|
||||
},
|
||||
async () => {
|
||||
try { return toolResult(await apiGet('/depots-retraits')); }
|
||||
async ({ workspace }) => {
|
||||
try { return toolResult(await apiGet('/depots-retraits', { workspace })); }
|
||||
catch (e) { return toolError(e); }
|
||||
},
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user