From eea69d77a4469c651141114f727116edd6ad11dc Mon Sep 17 00:00:00 2001 From: Olivier Date: Wed, 30 Sep 2026 16:26:14 +0200 Subject: [PATCH] =?UTF-8?q?Am=C3=A9lioration=20de=20la=20gestion=20du=20pa?= =?UTF-8?q?trimoine?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- _to_delete/dashboardPatrimoine.js.bak_round5 | 327 +++++++++++ backend/src/db/index.js | 285 +++++++++- backend/src/routes/adminWorkspaces.js | 16 +- backend/src/routes/comptes.js | 19 +- backend/src/routes/dashboardPatrimoine.js | 524 ++++++++++++++++++ .../src/routes/enveloppesReferentielPublic.js | 8 +- backend/src/routes/lignesPatrimoine.js | 109 ++++ .../routes/regroupementsPatrimoniauxPublic.js | 30 + backend/src/routes/transactionsPatrimoine.js | 130 +++++ backend/src/routes/valorisationsPatrimoine.js | 47 ++ backend/src/server.js | 12 + backend/src/utils/valorisationsPatrimoine.js | 41 ++ frontend/src/App.jsx | 30 + frontend/src/components/Layout.jsx | 130 +++-- .../src/components/PatrimoineAjoutModal.jsx | 208 +++++++ .../ValorisationPatrimoineChart.jsx | 305 ++++++++++ frontend/src/pages/ActifApercu.jsx | 167 ++++++ frontend/src/pages/DashboardPatrimoine.jsx | 364 ++++++++++++ frontend/src/pages/LignePatrimoineDetail.jsx | 466 ++++++++++++++++ .../src/pages/admin/WorkspacesSection.jsx | 12 +- .../src/pages/settings/ComptesSection.jsx | 24 +- frontend/src/styles.css | 29 + 22 files changed, 3212 insertions(+), 71 deletions(-) create mode 100644 _to_delete/dashboardPatrimoine.js.bak_round5 create mode 100644 backend/src/routes/dashboardPatrimoine.js create mode 100644 backend/src/routes/lignesPatrimoine.js create mode 100644 backend/src/routes/regroupementsPatrimoniauxPublic.js create mode 100644 backend/src/routes/transactionsPatrimoine.js create mode 100644 backend/src/routes/valorisationsPatrimoine.js create mode 100644 backend/src/utils/valorisationsPatrimoine.js create mode 100644 frontend/src/components/PatrimoineAjoutModal.jsx create mode 100644 frontend/src/components/ValorisationPatrimoineChart.jsx create mode 100644 frontend/src/pages/ActifApercu.jsx create mode 100644 frontend/src/pages/DashboardPatrimoine.jsx create mode 100644 frontend/src/pages/LignePatrimoineDetail.jsx diff --git a/_to_delete/dashboardPatrimoine.js.bak_round5 b/_to_delete/dashboardPatrimoine.js.bak_round5 new file mode 100644 index 0000000..badb4bb --- /dev/null +++ b/_to_delete/dashboardPatrimoine.js.bak_round5 @@ -0,0 +1,327 @@ +import { Router } from 'express'; +import db from '../db/index.js'; + +const router = Router(); +// requireAuth appliqué dans server.js + +/** + * routes/dashboardPatrimoine.js — vue consolidée du patrimoine par regroupement (workspace + * "Patrimoine", chantier du 28/09/26, demande Olivier : "j'aimerais que l'on réfléchisse sur le + * comment la restitution du patrimoine va se passer... un espace de travail particulier : + * 'Patrimoine'"). Lecture seule, scope utilisateur (req.user.id) — pas de CRUD ici, seulement + * de l'agrégation en lecture sur des données déjà gérées ailleurs (Paramètres > Mes comptes, + * Emprunts, Admin > Enveloppes & regroupements). + * + * Cadrage validé par Olivier (AskUserQuestion, 28/09/26 (suite)) : + * - Respecter l'accès utilisateur : Crowdlending/Private Equity ne sont montrés/agrégés que + * si l'utilisateur a effectivement accès à ces workspaces (granted_by_admin=1 — pas + * seulement actif_by_user, cf. userHasWorkspaceAccess ci-dessous). + * - Un seul dashboard consolidé pour démarrer (pas de sous-pages par regroupement en v1). + * + * Périmètre : + * - Comptes courants / Comptes d'épargne / Comptes d'investissement / Immobilier / Crypto / + * Autres actifs : somme de comptes.solde_eur (saisie manuelle, cf. migration solde_eur dans + * db/index.js) rattachés via comptes.enveloppe_id → enveloppes_referentiel + * .regroupement_patrimonial_id (cf. plan_regroupements_patrimoniaux.md). + * - Passif : somme de emprunts.capital_restant_du. + * - Crowdlending et Private Equity : PAS des comptes.solde_eur (ces 2 regroupements n'ont + * aucune enveloppe eligible_compte=1, cf. `structurellement_vide` ci-dessous) — le total + * vient directement du portefeuille réel de leur workspace dédié, avec EXACTEMENT la même + * métrique que celle déjà affichée là-bas (crowdlendingCapitalInvesti / + * privateEquityCapitalInvesti ci-dessous — pas une nouvelle définition, un ré-affichage à + * l'identique). Ajouté le 29/09/26 suite à la remarque d'Olivier, qui s'attendait à voir + * cette valeur dès la v1 initiale (celle-ci se contentait d'un placeholder + * `portefeuille_a_venir`). N'apparaît que si l'utilisateur a effectivement accès au + * workspace correspondant (`access.crowdlending` / `access.private_equity`) — sinon le nœud + * reste `portefeuille_a_venir: true` avec un total à 0, et le frontend le masque entièrement. + * + * `structurellement_vide` (aucune enveloppe eligible_compte=1 dans ce regroupement, cf. + * migration eligible_compte, db/index.js — le sélecteur "Enveloppe" de Mes comptes ne propose + * QUE les enveloppes eligible_compte=1) signale un regroupement qui ne peut recevoir AUCUN + * solde via comptes.solde_eur, quoi que l'utilisateur saisisse : aujourd'hui Immobilier et + * Autres actifs (0 enveloppe éligible chacun — vérifié empiriquement ci-dessous, pas codé en + * dur). Crowdlending et Private Equity ont eux aussi 0 enveloppe éligible mais ne portent PAS + * ce flag : leur total vient du portefeuille réel (ci-dessus), pas d'une saisie manuelle en + * attente. Distinct d'un total à 0 € réellement saisi : le frontend doit l'afficher + * explicitement ("pas encore de saisie possible ici") plutôt qu'un "0 €" qui laisserait croire + * que le patrimoine de cette famille est nul. + * + * `total_eur` d'un regroupement PARENT (aujourd'hui : "Comptes d'investissement", seul + * regroupement à avoir un enfant, "Private Equity") est un total ROULÉ, cf. buildNode : + * total propre du parent (ses comptes.solde_eur rattachés directement) + total de chacun de + * ses enfants. Corrigé le 29/09/26 (retour d'Olivier : "la somme des sous-catégories doit se + * retrouver remontée en total au niveau de 'comptes d'investissement'" — bug de la v1, qui + * affichait le total du parent sans ses enfants). + * + * `lignes` (29/09/26, retour d'Olivier : "je voudrais... un système... pour développer soit par + * plateforme ou par compte/livret/produit") — détail par regroupement pour l'affichage + * dépliable côté frontend : liste des comptes (nom, établissement, détenteur, solde) pour les + * regroupements alimentés par comptes.solde_eur, liste des emprunts pour Passif, et répartition + * par plateforme (capital investi, nb d'investissements, détenteur agrégé) pour Crowdlending/Private Equity — + * "je m'attendrais à voir au moins le capital investi par plateforme". Pas de colonne + * variation/performance (contrairement aux captures d'écran d'app tierce qu'Olivier a + * partagées en référence) : comptes.solde_eur et emprunts.capital_restant_du sont des valeurs + * instantanées, sans historique conservé en base — un vrai suivi de performance nécessiterait + * de capturer un solde à chaque date (hors périmètre de ce chantier). + */ + +function userHasWorkspaceAccess(userId, type) { + const row = db.prepare(` + SELECT 1 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.type = ? + LIMIT 1 + `).get(userId, type); + return !!row; +} + +// Regroupements dont le total ne vient pas de comptes.solde_eur mais du portefeuille réel du +// workspace dédié — cf. commentaire d'en-tête. Identifiés par nom (pas d'autre marqueur en base +// à ce jour) plutôt que par une colonne dédiée : seul cas d'usage pour l'instant, ne justifie +// pas une migration. +const PORTEFEUILLE_NOMS = new Set(['Crowdlending', 'Private Equity']); + +// "Capital investi" Crowdlending (29/09/26, suite à la remarque d'Olivier qui s'attendait à +// voir cette valeur dès la v1) — reprend EXACTEMENT la même métrique que le KPI "Capital +// investi" du Dashboard crowdlending (Dashboard.jsx : portfolio.encours + portfolio.en_defaut, +// cf. routes/dashboard.js) : capital actuellement engagé (statuts en_cours/en_retard/procedure +// uniquement — exclut rembourse/cloture), net des réinvestissements et remboursements de +// capital. Pas une nouvelle définition : un ré-affichage à l'identique, agrégé sur tous les +// investisseurs de l'utilisateur (comme comptes/emprunts ailleurs dans ce fichier). +const CL_CAPITAL_CASE = `CASE WHEN i.statut IN ('en_cours', 'en_retard', 'procedure') THEN + i.montant_investi + + COALESCE((SELECT SUM(rv.montant) FROM reinvestissements rv WHERE rv.investissement_id = i.id), 0) + - COALESCE((SELECT SUM(rb.capital) FROM remboursements rb WHERE rb.investissement_id = i.id AND rb.type = 'normal'), 0) + ELSE 0 END`; + +function crowdlendingCapitalInvesti(userId) { + const row = db.prepare(` + SELECT COALESCE(SUM(${CL_CAPITAL_CASE}), 0) AS capital_investi + FROM investissements i + WHERE i.investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?) + `).get(userId); + return row.capital_investi; +} + +// Détenteur agrégé pour une ligne groupée par plateforme (Crowdlending/PE) : GROUP_CONCAT +// DISTINCT ramène tous les détenteurs distincts séparés par virgule — un seul nom si tous les +// investissements de la plateforme partagent le même détenteur, "Plusieurs" s'ils divergent +// (foyer avec plusieurs investisseurs sur la même plateforme). +// +// Corrigé le 29/09/26 (suite, suite) : `inv.nom` seul, plus de concaténation avec `inv.prenom`. +// Olivier a signalé un second problème sur sa base réelle une fois la colonne Détenteur +// remplie ("Encore un pb sur l'affichage des détenteurs") : "Olivier Olivier CROGUENNEC" au +// lieu de "Olivier CROGUENNEC". Cause : investisseurs.nom stocke déjà le NOM COMPLET +// (prénom + nom de famille) pour un profil "famille" — cf. INSERT dans auth.js (création du +// profil principal à l'inscription : `nom = fullName`, `prenom` extrait séparément du même +// fullName) et FamilleEntreprises.jsx (le formulaire d'édition reconstruit `nom_famille` en +// retirant le prénom de `nom` : `m.nom.replace(m.prenom, '')`, preuve que `nom` contient déjà +// tout). Pour un profil "entreprise", `prenom` est NULL et `nom` est simplement la raison +// sociale. Dans les deux cas, `inv.nom` seul EST le nom d'affichage — le préfixer par +// `inv.prenom` (comme le faisaient encore `comptesLignesStmt`/`empruntsLignesStmt` plus bas, +// corrigés au même moment) dupliquait le prénom. Vérifié en lecture seule sur une copie locale +// de la base réelle d'Olivier (investisseurs.nom = "Olivier CROGUENNEC", "Capucine CROGUENNEC", +// "Marine CROGUENNEC" — jamais juste le nom de famille). +function toDetenteur(concat) { + if (!concat) return null; + const noms = [...new Set(concat.split(',').map(s => s.trim()).filter(Boolean))]; + if (noms.length === 0) return null; + if (noms.length === 1) return noms[0]; + return 'Plusieurs'; +} + +// Répartition du capital investi Crowdlending par plateforme (même métrique que +// crowdlendingCapitalInvesti ci-dessus, groupée) — demande Olivier 29/09/26 : "je m'attendrais +// à voir au moins le capital investi par plateforme". `detenteur` ajouté le 29/09/26 (suite) : +// Olivier a signalé que la colonne Détenteur restait vide sur ces lignes ("Cela n'a pas l'air +// de fonctionner pour les détenteurs") — chaque investissement porte bien un investisseur_id +// (détenteur), l'agrégation par plateforme se contentait jusque-là de ne pas le remonter. +function crowdlendingLignes(userId) { + const rows = db.prepare(` + SELECT p.id AS id, p.nom AS label, + COALESCE(SUM(${CL_CAPITAL_CASE}), 0) AS valeur_eur, + COUNT(CASE WHEN i.statut IN ('en_cours', 'en_retard', 'procedure') THEN 1 END) AS nb_lignes, + GROUP_CONCAT(DISTINCT inv.nom) AS detenteurs_concat + FROM investissements i + JOIN plateformes p ON p.id = i.plateforme_id + JOIN investisseurs inv ON inv.id = i.investisseur_id + WHERE i.investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?) + GROUP BY p.id, p.nom + HAVING valeur_eur > 0 + ORDER BY valeur_eur DESC + `).all(userId); + return rows.map(({ detenteurs_concat, ...r }) => ({ ...r, detenteur: toDetenteur(detenteurs_concat) })); +} + +// "Capital investi total" Private Equity — reprend EXACTEMENT la même métrique que le KPI +// "Capital investi total" du Dashboard PE (DashboardPe.jsx : somme de montant_investi sur tous +// les deals, tous statuts confondus — modèle PE différent du crowdlending, pas d'échéancier ni +// de notion d'encours net). Agrégé sur tous les investisseurs de l'utilisateur, tous workspaces +// de type private_equity confondus (le type peut avoir plusieurs exemplaires, cf. +// adminWorkspaces.js). +function privateEquityCapitalInvesti(userId) { + const row = db.prepare(` + SELECT COALESCE(SUM(montant_investi), 0) AS capital_investi + FROM investissements_pe + WHERE investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?) + `).get(userId); + return row.capital_investi; +} + +// `detenteur` ajouté le 29/09/26 — même correction que crowdlendingLignes ci-dessus. +function privateEquityLignes(userId) { + const rows = db.prepare(` + SELECT p.id AS id, p.nom AS label, + COALESCE(SUM(ipe.montant_investi), 0) AS valeur_eur, + COUNT(*) AS nb_lignes, + GROUP_CONCAT(DISTINCT inv.nom) AS detenteurs_concat + FROM investissements_pe ipe + JOIN plateformes p ON p.id = ipe.plateforme_id + JOIN investisseurs inv ON inv.id = ipe.investisseur_id + WHERE ipe.investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?) + GROUP BY p.id, p.nom + HAVING valeur_eur > 0 + ORDER BY valeur_eur DESC + `).all(userId); + return rows.map(({ detenteurs_concat, ...r }) => ({ ...r, detenteur: toDetenteur(detenteurs_concat) })); +} + +router.get('/', (req, res) => { + const userId = req.user.id; + + const roots = db.prepare(` + SELECT id, nom, parent_id, type, ordre_affichage + FROM regroupements_patrimoniaux + WHERE parent_id IS NULL + ORDER BY ordre_affichage, nom + `).all(); + const children = db.prepare(` + SELECT id, nom, parent_id, type, ordre_affichage + FROM regroupements_patrimoniaux + WHERE parent_id IS NOT NULL + ORDER BY ordre_affichage, nom + `).all(); + + const soldeStmt = db.prepare(` + SELECT + COALESCE(SUM(c.solde_eur), 0) AS total, + SUM(CASE WHEN c.solde_eur IS NOT NULL THEN 1 ELSE 0 END) AS nb_avec_solde, + SUM(CASE WHEN c.solde_eur IS NULL THEN 1 ELSE 0 END) AS nb_sans_solde + FROM comptes c + JOIN enveloppes_referentiel er ON er.id = c.enveloppe_id + WHERE c.user_id = ? AND er.regroupement_patrimonial_id = ? + `); + // Lignes de détail "par compte" — établissement (institution du référentiel, sinon le champ + // texte libre banque) et détenteur, pour l'affichage dépliable (cf. commentaire d'en-tête). + // Ne remonte que les comptes avec un solde saisi (NULL exclu, non pertinent en liste). + const comptesLignesStmt = db.prepare(` + SELECT c.id AS id, c.nom AS label, + COALESCE(ir.nom, c.banque) AS sous_label, + c.solde_eur AS valeur_eur, + inv.nom AS detenteur + FROM comptes c + LEFT JOIN institutions_referentiel ir ON ir.id = c.institution_id + LEFT JOIN investisseurs inv ON inv.id = c.investisseur_id + JOIN enveloppes_referentiel er ON er.id = c.enveloppe_id + WHERE c.user_id = ? AND er.regroupement_patrimonial_id = ? AND c.solde_eur IS NOT NULL + ORDER BY c.solde_eur DESC + `); + const eligibleCountStmt = db.prepare(` + SELECT COUNT(*) AS n FROM enveloppes_referentiel + WHERE regroupement_patrimonial_id = ? AND eligible_compte = 1 + `); + const passifStmt = db.prepare(` + SELECT COALESCE(SUM(capital_restant_du), 0) AS total, COUNT(*) AS n + FROM emprunts WHERE user_id = ? AND regroupement_patrimonial_id = ? + `); + const empruntsLignesStmt = db.prepare(` + SELECT e.id AS id, e.nom AS label, + ir.nom AS sous_label, + e.capital_restant_du AS valeur_eur, + inv.nom AS detenteur + FROM emprunts e + LEFT JOIN institutions_referentiel ir ON ir.id = e.institution_id + LEFT JOIN investisseurs inv ON inv.id = e.investisseur_id + WHERE e.user_id = ? AND e.regroupement_patrimonial_id = ? + ORDER BY e.capital_restant_du DESC + `); + + const accessCache = { + crowdlending: userHasWorkspaceAccess(userId, 'crowdlending'), + private_equity: userHasWorkspaceAccess(userId, 'private_equity'), + }; + + function buildNode(r) { + const isPortefeuille = PORTEFEUILLE_NOMS.has(r.nom); + const eligibleCount = eligibleCountStmt.get(r.id).n; + const structurellementVide = !isPortefeuille && eligibleCount === 0; + + let ownTotalEur = 0, nbAvecSolde = 0, nbSansSolde = 0, sourcePortefeuille = false, portefeuilleAVenir = false; + let lignes = []; + + if (r.nom === 'Crowdlending') { + if (accessCache.crowdlending) { + ownTotalEur = crowdlendingCapitalInvesti(userId); + sourcePortefeuille = true; + lignes = crowdlendingLignes(userId); + } else { + portefeuilleAVenir = true; + } + } else if (r.nom === 'Private Equity') { + if (accessCache.private_equity) { + ownTotalEur = privateEquityCapitalInvesti(userId); + sourcePortefeuille = true; + lignes = privateEquityLignes(userId); + } else { + portefeuilleAVenir = true; + } + } else if (r.type === 'actif') { + const s = soldeStmt.get(userId, r.id); + ownTotalEur = s.total; nbAvecSolde = s.nb_avec_solde; nbSansSolde = s.nb_sans_solde; + lignes = comptesLignesStmt.all(userId, r.id); + } else { + const p = passifStmt.get(userId, r.id); + ownTotalEur = p.total; nbAvecSolde = p.n; nbSansSolde = 0; + lignes = empruntsLignesStmt.all(userId, r.id); + } + + const enfants = children.filter(ch => ch.parent_id === r.id).map(buildNode); + // Total ROULÉ (29/09/26, correctif) : propre total + total de chaque enfant — un enfant + // (ex. Private Equity) n'a lui-même pas d'enfant (hiérarchie à 2 niveaux max, cf. + // regroupementsPatrimoniaux.js), donc pas de risque de double-comptage plus profond. + const totalEur = ownTotalEur + enfants.reduce((s, e) => s + e.total_eur, 0); + + return { + id: r.id, + nom: r.nom, + type: r.type, + ordre_affichage: r.ordre_affichage, + total_eur: totalEur, + nb_comptes_avec_solde: nbAvecSolde, + nb_comptes_sans_solde: nbSansSolde, + structurellement_vide: structurellementVide, + portefeuille_a_venir: portefeuilleAVenir, + source_portefeuille: sourcePortefeuille, + lignes, + enfants, + }; + } + + const tree = roots.map(buildNode); + + // Les totaux racine sont désormais roulés (ils incluent déjà leurs enfants, cf. buildNode) — + // une simple somme des racines par type suffit, plus besoin de descendre récursivement dans + // l'arbre (la hiérarchie ne dépasse de toute façon pas 2 niveaux, cf. plus haut). + const totalActif = tree.filter(n => n.type === 'actif').reduce((s, n) => s + n.total_eur, 0); + const totalPassif = tree.filter(n => n.type === 'passif').reduce((s, n) => s + n.total_eur, 0); + + res.json({ + access: accessCache, + total_actif_eur: totalActif, + total_passif_eur: totalPassif, + net_eur: totalActif - totalPassif, + regroupements: tree, + }); +}); + +export default router; diff --git a/backend/src/db/index.js b/backend/src/db/index.js index cbe478f..7ab77df 100644 --- a/backend/src/db/index.js +++ b/backend/src/db/index.js @@ -4328,9 +4328,20 @@ db.exec(` db.exec('CREATE INDEX IF NOT EXISTS idx_regroupatr_parent ON regroupements_patrimoniaux(parent_id)'); // Seed idempotent (n'insère que si la table est vide — l'admin reste libre de renommer/ -// réorganiser ensuite, comme pour les catégories). 7 regroupements racine "actif" + 1 -// "passif" (Passif, pour les emprunts), puis 1 sous-regroupement "Private Equity" rattaché à -// "Comptes d'investissement" (inséré dans un second temps, une fois l'id parent connu). +// réorganiser ensuite, comme pour les catégories). 6 regroupements racine "actif" + 1 +// "passif" (Passif, pour les emprunts), puis des sous-regroupements : "Private Equity", +// "Crowdlending" et "Bourse" sous "Comptes d'investissement" ; "Comptes courants rémunérés" et +// "Comptes courants non rémunérés" sous "Comptes courants" (insérés dans un second temps, une +// fois les id parents connus). Crowdlending a d'abord été seedé en racine (28/09/26), puis +// réintégré comme sous-catégorie de "Comptes d'investissement" le 30/09/26, à la demande +// d'Olivier — même statut que Private Equity : *"Finalement je voudrais réintégrer le +// Crowdlending comme sous-catégorie de comptes d'investissement."* "Bourse" (PEA/PEA-PME/CTO) +// et la scission "rémunérés"/"non rémunérés" des comptes courants ont été créées directement en +// sous-catégories le 30/09/26 (suite), mêmes demandes : *"J'aurais bien vu les PEA, PEA-PME, +// CTO dans un sous-catégorie qui pourrait être 'Bourse'"* puis *"Et mettra aussi en place un +// distinction entre comptes courants rémunérés et non rémunérés."* (Les installations +// existantes sont reparentées/complétées par les blocs de migration ci-dessous, pas par ce +// seed qui ne s'exécute qu'une fois, à vide.) if (db.prepare('SELECT COUNT(*) AS n FROM regroupements_patrimoniaux').get().n === 0) { const REGROUPEMENTS_RACINE = [ { nom: 'Immobilier', type: 'actif', ordre: 1 }, @@ -4338,9 +4349,8 @@ if (db.prepare('SELECT COUNT(*) AS n FROM regroupements_patrimoniaux').get().n = { nom: "Comptes d'épargne", type: 'actif', ordre: 3 }, { nom: 'Crypto', type: 'actif', ordre: 4 }, { nom: "Comptes d'investissement", type: 'actif', ordre: 5 }, - { nom: 'Crowdlending', type: 'actif', ordre: 6 }, - { nom: 'Autres actifs', type: 'actif', ordre: 7 }, - { nom: 'Passif', type: 'passif', ordre: 8 }, + { nom: 'Autres actifs', type: 'actif', ordre: 6 }, + { nom: 'Passif', type: 'passif', ordre: 7 }, ]; const insRoot = db.prepare( 'INSERT INTO regroupements_patrimoniaux (nom, type, ordre_affichage) VALUES (?, ?, ?)' @@ -4349,12 +4359,107 @@ if (db.prepare('SELECT COUNT(*) AS n FROM regroupements_patrimoniaux').get().n = db.transaction(() => { for (const r of REGROUPEMENTS_RACINE) insRoot.run(r.nom, r.type, r.ordre); const comptesInv = getByNom.get("Comptes d'investissement"); - db.prepare( + const insChild = db.prepare( 'INSERT INTO regroupements_patrimoniaux (nom, parent_id, type, ordre_affichage) VALUES (?, ?, ?, ?)' - ).run('Private Equity', comptesInv.id, 'actif', 1); + ); + insChild.run('Private Equity', comptesInv.id, 'actif', 1); + insChild.run('Crowdlending', comptesInv.id, 'actif', 2); + insChild.run('Bourse', comptesInv.id, 'actif', 3); + const comptesCourants = getByNom.get('Comptes courants'); + insChild.run('Comptes courants non rémunérés', comptesCourants.id, 'actif', 1); + insChild.run('Comptes courants rémunérés', comptesCourants.id, 'actif', 2); })(); } +// Migration (30/09/26, demande Olivier — cf. commentaire ci-dessus et claude/ +// plan_regroupements_patrimoniaux.md) : réintègre "Crowdlending" comme sous-regroupement de +// "Comptes d'investissement" sur une installation existante, où le seed ci-dessus (qui ne +// s'exécute qu'à table vide) a déjà créé "Crowdlending" en racine (28/09/26). Gardée par +// `parent_id IS NULL` sur la ligne "Crowdlending" elle-même (pas un backfill générique sur +// toute la table — les autres regroupements restent volontairement en racine) : idempotente, +// et ne rejoue jamais après le premier passage puisque parent_id ne sera alors plus NULL. +// ordre_affichage aligné sur le seed ci-dessus (2, juste après Private Equity = 1) pour que +// les deux chemins — nouvelle installation ou migration d'une installation existante — +// convergent vers le même état final. +{ + const cl = db.prepare("SELECT id, parent_id FROM regroupements_patrimoniaux WHERE nom = 'Crowdlending'").get(); + const comptesInv = db.prepare("SELECT id FROM regroupements_patrimoniaux WHERE nom = 'Comptes d''investissement'").get(); + if (cl && comptesInv && cl.parent_id === null) { + db.prepare( + "UPDATE regroupements_patrimoniaux SET parent_id = ?, type = 'actif', ordre_affichage = 2, updated_at = datetime('now') WHERE id = ?" + ).run(comptesInv.id, cl.id); + } +} + +// Migration (30/09/26 (suite), demande Olivier — cf. claude/plan_regroupements_patrimoniaux.md) +// : crée "Bourse" comme sous-regroupement de "Comptes d'investissement" sur une installation +// existante (le seed ci-dessus ne s'exécute qu'à table vide), et y déplace les 3 enveloppes +// PEA/PEA-PME/CTO, jusqu'ici directement sous "Comptes d'investissement". Olivier : *"J'aurais +// bien vu les PEA, PEA-PME, CTO dans un sous-catégorie qui pourrait être 'Bourse'."* +// +// Gardée en deux temps, chacun idempotent : +// 1. Création de "Bourse" seulement si elle n'existe pas encore (par nom, comme tout +// regroupement) — ordre_affichage=3, juste après Crowdlending=2, pour converger vers le +// même état final que le seed sur une nouvelle installation. +// 2. Déplacement des 3 enveloppes SEULEMENT si elles sont encore exactement là où l'ancienne +// répartition les avait placées (`regroupement_patrimonial_id = comptesInv.id`) — jamais +// un backfill générique sur `IS NULL` comme pour le rattachement initial : ces enveloppes +// étaient déjà rattachées (à "Comptes d'investissement"), donc `IS NULL` ne les aurait pas +// touchées. Ce guard plus précis évite en particulier d'écraser un rattachement qu'un +// administrateur aurait déjà modifié manuellement vers un autre regroupement. +{ + const comptesInv = db.prepare("SELECT id FROM regroupements_patrimoniaux WHERE nom = 'Comptes d''investissement'").get(); + if (comptesInv) { + let bourse = db.prepare("SELECT id FROM regroupements_patrimoniaux WHERE nom = 'Bourse'").get(); + if (!bourse) { + db.prepare( + "INSERT INTO regroupements_patrimoniaux (nom, parent_id, type, ordre_affichage) VALUES ('Bourse', ?, 'actif', 3)" + ).run(comptesInv.id); + bourse = db.prepare("SELECT id FROM regroupements_patrimoniaux WHERE nom = 'Bourse'").get(); + } + const moveToBourse = db.prepare( + "UPDATE enveloppes_referentiel SET regroupement_patrimonial_id = ? WHERE nom = ? AND regroupement_patrimonial_id = ?" + ); + for (const envNom of ['PEA', 'PEA-PME', 'Compte-titres ordinaire (CTO)']) { + moveToBourse.run(bourse.id, envNom, comptesInv.id); + } + } +} + +// Migration (30/09/26 (suite), demande Olivier — cf. claude/plan_regroupements_patrimoniaux.md) +// : scinde "Comptes courants" en deux sous-regroupements "Comptes courants non rémunérés" et +// "Comptes courants rémunérés" sur une installation existante, même mécanique que "Bourse" +// ci-dessus (création si absente, puis déplacement des enveloppes concernées, gardé par leur +// rattachement actuel à "Comptes courants" pour ne jamais écraser un rattachement déjà modifié +// manuellement). Olivier : *"Et mettra aussi en place un distinction entre comptes courants +// rémunérés et non rémunérés."* +{ + const comptesCourants = db.prepare("SELECT id FROM regroupements_patrimoniaux WHERE nom = 'Comptes courants'").get(); + if (comptesCourants) { + let ccNonRem = db.prepare("SELECT id FROM regroupements_patrimoniaux WHERE nom = 'Comptes courants non rémunérés'").get(); + if (!ccNonRem) { + db.prepare( + "INSERT INTO regroupements_patrimoniaux (nom, parent_id, type, ordre_affichage) VALUES ('Comptes courants non rémunérés', ?, 'actif', 1)" + ).run(comptesCourants.id); + ccNonRem = db.prepare("SELECT id FROM regroupements_patrimoniaux WHERE nom = 'Comptes courants non rémunérés'").get(); + } + let ccRem = db.prepare("SELECT id FROM regroupements_patrimoniaux WHERE nom = 'Comptes courants rémunérés'").get(); + if (!ccRem) { + db.prepare( + "INSERT INTO regroupements_patrimoniaux (nom, parent_id, type, ordre_affichage) VALUES ('Comptes courants rémunérés', ?, 'actif', 2)" + ).run(comptesCourants.id); + ccRem = db.prepare("SELECT id FROM regroupements_patrimoniaux WHERE nom = 'Comptes courants rémunérés'").get(); + } + const moveTo = db.prepare( + "UPDATE enveloppes_referentiel SET regroupement_patrimonial_id = ? WHERE nom = ? AND regroupement_patrimonial_id = ?" + ); + moveTo.run(ccNonRem.id, 'Compte courant non rémunéré', comptesCourants.id); + for (const envNom of ['Compte courant rémunéré', 'Compte rémunéré nouvelle génération (fintech)']) { + moveTo.run(ccRem.id, envNom, comptesCourants.id); + } + } +} + // Colonne de rattachement sur enveloppes_referentiel — ALTER gardé par PRAGMA table_info, // même patron que eligible_compte/compte_reglement_par_defaut ci-dessus. { @@ -4370,11 +4475,18 @@ if (db.prepare('SELECT COUNT(*) AS n FROM regroupements_patrimoniaux').get().n = // précédente de ce même bloc (idempotent). Mapping construit en croisant les 46 noms exacts du // catalogue (SEED_ENVELOPPES + EXTRA_ENVELOPPES ci-dessus) avec le regroupement demandé par // Olivier ; répartition documentée dans claude/plan_regroupements_patrimoniaux.md (doc projet). +// Mis à jour le 30/09/26 pour pointer directement vers les sous-regroupements "Bourse" et +// "Comptes courants rémunérés"/"non rémunérés" plutôt que leurs parents — ce bloc ne concerne +// que les installations neuves (regroupement_patrimonial_id encore NULL) ; les installations +// existantes sont reparentées par les migrations dédiées ci-dessus (guard sur l'ancien parent, +// pas sur IS NULL, puisque ces enveloppes étaient déjà rattachées). { const ENVELOPPE_REGROUPEMENT = { - 'Comptes courants': [ - 'Compte courant non rémunéré', 'Compte courant rémunéré', - 'Compte rémunéré nouvelle génération (fintech)', + 'Comptes courants non rémunérés': [ + 'Compte courant non rémunéré', + ], + 'Comptes courants rémunérés': [ + 'Compte courant rémunéré', 'Compte rémunéré nouvelle génération (fintech)', ], "Comptes d'épargne": [ 'Livret A', 'LDDS', 'LEP', 'Livret Jeune', 'CEL', 'PEL', @@ -4384,6 +4496,8 @@ if (db.prepare('SELECT COUNT(*) AS n FROM regroupements_patrimoniaux').get().n = 'Assurance-vie monosupport (fonds euros)', 'Assurance-vie multisupport', 'Contrat de capitalisation', 'Assurance-vie luxembourgeoise', 'PER individuel', 'PER collectif / obligatoire (PERCOL, article 83)', 'PEE', + ], + 'Bourse': [ 'PEA', 'PEA-PME', 'Compte-titres ordinaire (CTO)', ], 'Private Equity': [ @@ -4442,4 +4556,153 @@ if (db.prepare('SELECT COUNT(*) AS n FROM regroupements_patrimoniaux').get().n = } } +// ── Migration : solde_eur sur comptes (chantier Workspace Patrimoine, 28/09/26) ─────────── +// Aucun champ monétaire n'existait sur `comptes` jusqu'ici (nom/banque/enveloppe sont purement +// descriptifs) — bloquant pour la restitution patrimoniale consolidée demandée par Olivier +// (workspace "Patrimoine", cf. plus bas) : sans montant, aucune vue "combien j'ai sur chaque +// famille d'actifs" n'est possible. Saisie MANUELLE pour l'instant (décision Olivier, +// AskUserQuestion "Ajouter un solde manuel sur comptes maintenant (recommandé)") — pas de +// valorisation automatique (ex. dernier relevé, cours de bourse) : ce sera un chantier séparé +// si besoin. Nullable et non rétroactif : un compte existant sans saisie affiche simplement +// "non renseigné" plutôt que 0€, pour ne pas fausser silencieusement un total. +{ + const cols = db.prepare('PRAGMA table_info(comptes)').all().map(c => c.name); + if (!cols.includes('solde_eur')) { + db.exec('ALTER TABLE comptes ADD COLUMN solde_eur REAL'); + } +} + +// ── Migration : workspaces.type 'patrimoine' — singleton (chantier Workspace Patrimoine, +// 28/09/26) ──────────────────────────────────────────────────────────────────────────────── +// Même régime que idx_workspaces_type_crowdlending_singleton ci-dessus : au plus UN workspace +// de type 'patrimoine' (dashboard consolidé unique — décision Olivier, AskUserQuestion +// "Singleton (recommandé)"). 'patrimoine' est ajouté à WORKSPACE_TYPES côté +// routes/adminWorkspaces.js — cf. ce fichier pour le contexte complet de ce chantier +// (project doc claude/plan_workspace_patrimoine.md). +db.exec(` + CREATE UNIQUE INDEX IF NOT EXISTS idx_workspaces_type_patrimoine_singleton + ON workspaces(type) WHERE type = 'patrimoine' +`); + +// ── Migration : lignes_patrimoine + transactions_patrimoine (chantier "Visualisation d'un +// actif", relance Phase 3b, 30/09/26) ───────────────────────────────────────────────────── +// La Phase 3b ("ligne de patrimoine") avait été explicitement mise en pause le 28/09/26 (cf. +// claude/plan_referentiel_enveloppes_supports.md, "on attend pour la 3b pour l'instant") — le +// cadrage à deux chemins de rattachement (compte existant, ou enveloppe rattachée directement +// sans compte intermédiaire) y avait déjà été validé sans être implémenté. Olivier (30/09/26, +// verbatim) : "j'aimerais travailler sur une notion de visualisation d'un actif [...] Cela +// pourra servir par exemple d'interface de saisie de la valeur actuelle de l'actif." — relance +// ce chantier. Cadrage complet dans claude/plan_visualisation_actif.md (project doc). +// +// `lignes_patrimoine` couvre le second chemin déjà validé (enveloppe rattachée directement, +// sans compte) : une position patrimoniale concrète pour les enveloppes aujourd'hui sans aucun +// moyen de recevoir une valeur (Immobilier, Autres actifs, Crypto hors exchange...) — ex. "Ma +// résidence principale", "Montre de collection". Toujours rattachée à une enveloppe (jamais de +// type libre), comme un compte l'est déjà. `institution_id` optionnel (ex. le notaire pour un +// bien immobilier), sur le même principe non contraignant que comptes.institution_id. +// `valeur_eur` est la valeur actuelle en repli/saisie directe — cf. transactions_patrimoine +// ci-dessous pour le cas où elle est calculée depuis un historique de mouvements réels. +db.exec(` + CREATE TABLE IF NOT EXISTS lignes_patrimoine ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + investisseur_id INTEGER REFERENCES investisseurs(id) ON DELETE SET NULL, + enveloppe_id INTEGER NOT NULL REFERENCES enveloppes_referentiel(id), + institution_id INTEGER REFERENCES institutions_referentiel(id) ON DELETE SET NULL, + nom TEXT NOT NULL, + valeur_eur REAL, + 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_lignespat_user ON lignes_patrimoine(user_id)'); +db.exec('CREATE INDEX IF NOT EXISTS idx_lignespat_enveloppe ON lignes_patrimoine(enveloppe_id)'); +db.exec('CREATE INDEX IF NOT EXISTS idx_lignespat_investisseur ON lignes_patrimoine(investisseur_id)'); + +// `transactions_patrimoine` — "de vrais mouvements (dépôt/retrait/achat/vente)" (Olivier, +// 30/09/26, réponse à la question de cadrage sur la nature des transactions), sur le patron déjà +// éprouvé de `depots_retraits` (cf. db/schema.sql), généralisé à une cible polymorphe : soit un +// compte existant, soit une ligne de patrimoine, jamais les deux (CHECK ci-dessous) — même +// principe d'exclusivité que le double chemin de rattachement Phase 3b. 5ème type "reevaluation" +// ajouté au-delà des 4 cités par Olivier : un bien immobilier ou un objet de collection ne se +// "dépose"/"retire" pas comme un compte, sa valeur change par appréciation/dépréciation +// constatée plutôt que par un flux d'argent réel — sans ce type, la section Transactions d'un +// tel actif resterait vide en pratique (cf. claude/plan_visualisation_actif.md, "Point à +// valider #2"). Convention de signe sur `montant` : dépôt/achat = valeur ajoutée (positive), +// retrait/vente = valeur retirée (positive, le retrait est appliqué en négatif au calcul de la +// valeur courante), reevaluation = variation signée directe (peut être négative) — appliquée +// telle quelle, pas de contrainte de signe en base sur ce type précis, donc pas de CHECK sur le +// signe de `montant` lui-même (validation du signe attendu par type faite côté route/Zod). +db.exec(` + CREATE TABLE IF NOT EXISTS transactions_patrimoine ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + compte_id INTEGER REFERENCES comptes(id) ON DELETE CASCADE, + ligne_patrimoine_id INTEGER REFERENCES lignes_patrimoine(id) ON DELETE CASCADE, + date_operation TEXT NOT NULL, + type TEXT NOT NULL CHECK(type IN ('depot','retrait','achat','vente','reevaluation')), + montant REAL NOT NULL, + libelle TEXT, + notes TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + CHECK ((compte_id IS NOT NULL) + (ligne_patrimoine_id IS NOT NULL) = 1) + ) +`); +db.exec('CREATE INDEX IF NOT EXISTS idx_transpat_compte ON transactions_patrimoine(compte_id, date_operation)'); +db.exec('CREATE INDEX IF NOT EXISTS idx_transpat_ligne ON transactions_patrimoine(ligne_patrimoine_id, date_operation)'); + +// ── Migration : valorisations_patrimoine (chantier "Historique de valeur", 30/09/26, suite) ── +// Olivier (verbatim) : "quand je rentre manuellement une valeur à un actif, je voudrais que tu +// gardes quelque part une association avec une date de valeur. Cela va nous permettre de tracer +// des graphiques de progression de la valeur dans le temps." Décidé via AskUserQuestion +// (30/09/26) : table dédiée plutôt qu'une extension de transactions_patrimoine — même modèle +// que investissements_pe_valorisations (Private Equity, 17/09/26), déjà éprouvé pour ce même +// besoin (date + valeur + note, "dernière valorisation" = la plus récente par date). Cible +// polymorphe compte_id / ligne_patrimoine_id (même patron d'exclusivité que +// transactions_patrimoine ci-dessus) — emprunts explicitement hors périmètre (AskUserQuestion, +// "Non, actifs uniquement"). comptes.solde_eur / lignes_patrimoine.valeur_eur restent la valeur +// courante affichée partout (aucun changement de leur sémantique) ; chaque saisie manuelle y +// ajoute désormais aussi un point d'historique, écrit par les routes (comptes.js, +// lignesPatrimoine.js), pas ici. Pour une ligne_patrimoine qui a des transactions réelles +// (dépôt/retrait/achat/vente/reevaluation), l'historique de valeur se reconstitue déjà depuis +// transactions_patrimoine (somme cumulée datée) — cette nouvelle table ne sert alors pas au +// graphique de cette ligne précise, mais reste écrite pour rester simple côté backend (elle est +// juste ignorée côté frontend dans ce cas). +db.exec(` + CREATE TABLE IF NOT EXISTS valorisations_patrimoine ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + compte_id INTEGER REFERENCES comptes(id) ON DELETE CASCADE, + ligne_patrimoine_id INTEGER REFERENCES lignes_patrimoine(id) ON DELETE CASCADE, + date_valorisation TEXT NOT NULL, + valeur REAL NOT NULL, + notes TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + CHECK ((compte_id IS NOT NULL) + (ligne_patrimoine_id IS NOT NULL) = 1) + ) +`); +db.exec('CREATE INDEX IF NOT EXISTS idx_valpat_compte ON valorisations_patrimoine(compte_id, date_valorisation)'); +db.exec('CREATE INDEX IF NOT EXISTS idx_valpat_ligne ON valorisations_patrimoine(ligne_patrimoine_id, date_valorisation)'); + +// Backfill (toujours exécuté, gardé par une sous-requête NOT EXISTS) : un premier point +// d'historique daté d'aujourd'hui pour chaque compte/ligne_patrimoine déjà valorisé avant la +// mise en place de ce mécanisme — décidé via AskUserQuestion ("Backfill à aujourd'hui") plutôt +// que de laisser l'historique démarrer vide. NOT EXISTS (pas seulement "table vide") : reste +// idempotent même après que l'utilisateur a commencé à saisir ses propres points d'historique +// pour d'AUTRES comptes/lignes — ne rejoue jamais sur une cible qui a déjà au moins un point. +db.exec(` + INSERT INTO valorisations_patrimoine (compte_id, date_valorisation, valeur) + SELECT c.id, date('now'), c.solde_eur + FROM comptes c + WHERE c.solde_eur IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM valorisations_patrimoine v WHERE v.compte_id = c.id) +`); +db.exec(` + INSERT INTO valorisations_patrimoine (ligne_patrimoine_id, date_valorisation, valeur) + SELECT lp.id, date('now'), lp.valeur_eur + FROM lignes_patrimoine lp + WHERE lp.valeur_eur IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM valorisations_patrimoine v WHERE v.ligne_patrimoine_id = lp.id) +`); + export default db; diff --git a/backend/src/routes/adminWorkspaces.js b/backend/src/routes/adminWorkspaces.js index 1dcbb95..db46860 100644 --- a/backend/src/routes/adminWorkspaces.js +++ b/backend/src/routes/adminWorkspaces.js @@ -18,9 +18,12 @@ const router = Router(); // de cette migration). 'crowdlending' est le singleton historique (un seul exemplaire, imposé // par l'index unique partiel côté DB et re-vérifié ici pour un message d'erreur clair) ; // 'private_equity' peut avoir plusieurs exemplaires (ex. si Olivier veut un jour séparer deux -// portefeuilles PE distincts). Un 3e type nécessiterait d'abord une page/route dédiée avant +// portefeuilles PE distincts). 'patrimoine' (28/09/26, chantier Workspace Patrimoine — cf. +// claude/plan_workspace_patrimoine.md) est un 2e singleton (dashboard consolidé unique, décision +// Olivier) — même régime que crowdlending, imposé par idx_workspaces_type_patrimoine_singleton +// côté DB et re-vérifié ici. Un 4e type nécessiterait d'abord une page/route dédiée avant // d'avoir un sens ici — l'ajouter à cette liste seul ne suffit pas. -const WORKSPACE_TYPES = ['crowdlending', 'private_equity']; +const WORKSPACE_TYPES = ['crowdlending', 'private_equity', 'patrimoine']; router.get('/', (_req, res) => { const rows = db.prepare(` @@ -53,12 +56,17 @@ router.post('/', (req, res, next) => { const body = WorkspaceCreateSchema.parse(req.body); const exists = db.prepare('SELECT id FROM workspaces WHERE slug = ?').get(body.slug); if (exists) throw new HttpError(409, 'Ce slug est déjà utilisé'); - // Cf. l'index unique partiel côté DB (idx_workspaces_type_crowdlending_singleton) : cette - // vérification applicative donne un message clair avant même d'atteindre la contrainte SQL. + // Cf. l'index unique partiel côté DB (idx_workspaces_type_crowdlending_singleton / + // idx_workspaces_type_patrimoine_singleton) : cette vérification applicative donne un + // message clair avant même d'atteindre la contrainte SQL. if (body.type === 'crowdlending') { const alreadyCl = db.prepare("SELECT id FROM workspaces WHERE type = 'crowdlending'").get(); if (alreadyCl) throw new HttpError(409, 'Un workspace de type Crowdlending existe déjà'); } + if (body.type === 'patrimoine') { + const alreadyPat = db.prepare("SELECT id FROM workspaces WHERE type = 'patrimoine'").get(); + if (alreadyPat) throw new HttpError(409, 'Un workspace de type Patrimoine existe déjà'); + } const r = db.prepare(` INSERT INTO workspaces (slug, nom, libelle_menu, description, type, actif_global, ordre) diff --git a/backend/src/routes/comptes.js b/backend/src/routes/comptes.js index cfc87a2..ee9e1cb 100644 --- a/backend/src/routes/comptes.js +++ b/backend/src/routes/comptes.js @@ -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 { recordValorisation } from '../utils/valorisationsPatrimoine.js'; const router = Router(); @@ -21,6 +22,9 @@ const Schema = z.object({ // dans db/index.js). Au plus un compte par investisseur ; l'exclusivité est appliquée // ci-dessous via clearOtherDefaults(), pas via une contrainte SQL (investisseur_id nullable). compte_reglement_par_defaut: z.boolean().optional().default(false), + // Chantier Workspace Patrimoine (28/09/26) — saisie manuelle, cf. migration solde_eur dans + // db/index.js. Nullable : un compte sans saisie reste "non renseigné" plutôt que 0€. + solde_eur: z.number().nullable().optional(), }); function clearOtherDefaults(userId, investisseurId, excludeId) { @@ -33,7 +37,7 @@ function clearOtherDefaults(userId, investisseurId, excludeId) { router.get('/', (req, res) => { const rows = db.prepare(` SELECT c.id, c.nom, c.banque, c.institution_id, c.enveloppe_id, c.exoneration_fiscale, - c.investisseur_id, c.compte_reglement_par_defaut, c.created_at, + c.investisseur_id, c.compte_reglement_par_defaut, c.solde_eur, c.created_at, inv.nom AS investisseur_nom, inv.prenom AS investisseur_prenom, inv.type AS investisseur_type, inv.type_fiscal AS investisseur_type_fiscal, ir.nom AS institution_nom, ir.domiciliation AS institution_domiciliation, @@ -56,8 +60,9 @@ router.post('/', (req, res, next) => { const id = db.transaction(() => { if (defaut) clearOtherDefaults(req.user.id, body.investisseur_id); const r = db.prepare( - 'INSERT INTO comptes (user_id, nom, banque, institution_id, enveloppe_id, investisseur_id, exoneration_fiscale, compte_reglement_par_defaut) VALUES (?,?,?,?,?,?,?,?)' - ).run(req.user.id, body.nom, body.banque || null, body.institution_id ?? null, body.enveloppe_id ?? null, body.investisseur_id ?? null, body.exoneration_fiscale ?? 'aucune', defaut); + 'INSERT INTO comptes (user_id, nom, banque, institution_id, enveloppe_id, investisseur_id, exoneration_fiscale, compte_reglement_par_defaut, solde_eur) VALUES (?,?,?,?,?,?,?,?,?)' + ).run(req.user.id, body.nom, body.banque || null, body.institution_id ?? null, body.enveloppe_id ?? null, body.investisseur_id ?? null, body.exoneration_fiscale ?? 'aucune', defaut, body.solde_eur ?? null); + recordValorisation({ compteId: r.lastInsertRowid, valeur: body.solde_eur }); return r.lastInsertRowid; })(); res.status(201).json({ id, ...body }); @@ -70,9 +75,11 @@ router.put('/:id', (req, res, next) => { const defaut = body.compte_reglement_par_defaut ? 1 : 0; const changes = db.transaction(() => { if (defaut) clearOtherDefaults(req.user.id, body.investisseur_id, Number(req.params.id)); - return db.prepare( - `UPDATE comptes SET nom=?, banque=?, institution_id=?, enveloppe_id=?, investisseur_id=?, exoneration_fiscale=?, compte_reglement_par_defaut=?, updated_at=datetime('now') WHERE id=? AND user_id=?` - ).run(body.nom, body.banque || null, body.institution_id ?? null, body.enveloppe_id ?? null, body.investisseur_id ?? null, body.exoneration_fiscale ?? 'aucune', defaut, req.params.id, req.user.id).changes; + const result = db.prepare( + `UPDATE comptes SET nom=?, banque=?, institution_id=?, enveloppe_id=?, investisseur_id=?, exoneration_fiscale=?, compte_reglement_par_defaut=?, solde_eur=?, updated_at=datetime('now') WHERE id=? AND user_id=?` + ).run(body.nom, body.banque || null, body.institution_id ?? null, body.enveloppe_id ?? null, body.investisseur_id ?? null, body.exoneration_fiscale ?? 'aucune', defaut, body.solde_eur ?? null, req.params.id, req.user.id).changes; + if (result > 0) recordValorisation({ compteId: Number(req.params.id), valeur: body.solde_eur }); + return result; })(); if (changes === 0) throw new HttpError(404, 'Not found'); res.json({ id: Number(req.params.id), ...body }); diff --git a/backend/src/routes/dashboardPatrimoine.js b/backend/src/routes/dashboardPatrimoine.js new file mode 100644 index 0000000..85cc92b --- /dev/null +++ b/backend/src/routes/dashboardPatrimoine.js @@ -0,0 +1,524 @@ +import { Router } from 'express'; +import db from '../db/index.js'; + +const router = Router(); +// requireAuth appliqué dans server.js + +/** + * routes/dashboardPatrimoine.js — vue consolidée du patrimoine par regroupement (workspace + * "Patrimoine", chantier du 28/09/26, demande Olivier : "j'aimerais que l'on réfléchisse sur le + * comment la restitution du patrimoine va se passer... un espace de travail particulier : + * 'Patrimoine'"). Lecture seule, scope utilisateur (req.user.id) — pas de CRUD ici, seulement + * de l'agrégation en lecture sur des données déjà gérées ailleurs (Paramètres > Mes comptes, + * Emprunts, Admin > Enveloppes & regroupements). + * + * Cadrage validé par Olivier (AskUserQuestion, 28/09/26 (suite)) : + * - Respecter l'accès utilisateur : Crowdlending/Private Equity ne sont montrés/agrégés que + * si l'utilisateur a effectivement accès à ces workspaces (granted_by_admin=1 — pas + * seulement actif_by_user, cf. userHasWorkspaceAccess ci-dessous). + * - Un seul dashboard consolidé pour démarrer (pas de sous-pages par regroupement en v1). + * + * Périmètre : + * - Comptes courants / Comptes d'épargne / Comptes d'investissement / Immobilier / Crypto / + * Autres actifs : somme de comptes.solde_eur (saisie manuelle, cf. migration solde_eur dans + * db/index.js) rattachés via comptes.enveloppe_id → enveloppes_referentiel + * .regroupement_patrimonial_id (cf. plan_regroupements_patrimoniaux.md). + * - Passif : somme de emprunts.capital_restant_du. + * - Crowdlending et Private Equity : PAS des comptes.solde_eur (ces 2 regroupements n'ont + * aucune enveloppe eligible_compte=1, cf. `structurellement_vide` ci-dessous) — le total + * vient directement du portefeuille réel de leur workspace dédié, avec EXACTEMENT la même + * métrique que celle déjà affichée là-bas (crowdlendingCapitalInvesti / + * privateEquityCapitalInvesti ci-dessous — pas une nouvelle définition, un ré-affichage à + * l'identique). Ajouté le 29/09/26 suite à la remarque d'Olivier, qui s'attendait à voir + * cette valeur dès la v1 initiale (celle-ci se contentait d'un placeholder + * `portefeuille_a_venir`). N'apparaît que si l'utilisateur a effectivement accès au + * workspace correspondant (`access.crowdlending` / `access.private_equity`) — sinon le nœud + * reste `portefeuille_a_venir: true` avec un total à 0, et le frontend le masque entièrement. + * + * `structurellement_vide` (aucune enveloppe eligible_compte=1 dans ce regroupement, cf. + * migration eligible_compte, db/index.js — le sélecteur "Enveloppe" de Mes comptes ne propose + * QUE les enveloppes eligible_compte=1) signale un regroupement qui ne peut recevoir AUCUN + * solde via comptes.solde_eur, quoi que l'utilisateur saisisse : aujourd'hui Immobilier et + * Autres actifs (0 enveloppe éligible chacun — vérifié empiriquement ci-dessous, pas codé en + * dur). Crowdlending et Private Equity ont eux aussi 0 enveloppe éligible mais ne portent PAS + * ce flag : leur total vient du portefeuille réel (ci-dessus), pas d'une saisie manuelle en + * attente. Distinct d'un total à 0 € réellement saisi : le frontend doit l'afficher + * explicitement ("pas encore de saisie possible ici") plutôt qu'un "0 €" qui laisserait croire + * que le patrimoine de cette famille est nul. + * + * `total_eur` d'un regroupement PARENT (aujourd'hui : "Comptes d'investissement", seul + * regroupement à avoir un enfant, "Private Equity") est un total ROULÉ, cf. buildNode : + * total propre du parent (ses comptes.solde_eur rattachés directement) + total de chacun de + * ses enfants. Corrigé le 29/09/26 (retour d'Olivier : "la somme des sous-catégories doit se + * retrouver remontée en total au niveau de 'comptes d'investissement'" — bug de la v1, qui + * affichait le total du parent sans ses enfants). + * + * `lignes` (29/09/26, retour d'Olivier : "je voudrais... un système... pour développer soit par + * plateforme ou par compte/livret/produit") — détail par regroupement pour l'affichage + * dépliable côté frontend : liste des comptes (nom, établissement, détenteur, solde) pour les + * regroupements alimentés par comptes.solde_eur, liste des emprunts pour Passif, et répartition + * par plateforme (capital investi, nb d'investissements, détenteur agrégé) pour Crowdlending/Private Equity — + * "je m'attendrais à voir au moins le capital investi par plateforme". Pas de colonne + * variation/performance (contrairement aux captures d'écran d'app tierce qu'Olivier a + * partagées en référence) : comptes.solde_eur et emprunts.capital_restant_du sont des valeurs + * instantanées, sans historique conservé en base — un vrai suivi de performance nécessiterait + * de capturer un solde à chaque date (hors périmètre de ce chantier). + * + * Scope investisseur (29/09/26, correctif) — Olivier, sélecteur "profil actif" de la sidebar + * (InvestisseurContext.jsx) réglé sur "Capucine CROGUENNEC" : *"La sélection d'un détenteur ne + * semble pas filtrer le patrimoine."* La v1 ignorait complètement ce sélecteur et agrégeait + * systématiquement TOUS les investisseurs du foyer, quel que soit le profil actif — alors que + * /api/dashboard (Crowdlending) et /api/dashboard-pe respectent tous les deux le même contrat : + * `?scope=all` agrège tout le foyer, sinon on filtre strictement sur l'investisseur donné par + * le header `X-Investisseur-Id` (envoyé automatiquement par frontend/src/api.js à partir du + * `localStorage` que gère InvestisseurContext — cf. routes/dashboard.js pour le même motif). + * Cette route applique désormais le même contrat, cf. resolveInvestisseurScope ci-dessous, mais + * avec DEUX variantes de clause selon la table (distinction qui n'existe pas dans + * routes/dashboard.js, où toutes les tables scopées n'ont pas de user_id propre) : + * - `invClauseStrict` (investissements, investissements_pe) : ces tables n'ont PAS de colonne + * user_id — c'est le filtre investisseur qui délimite la frontière de propriété entre + * utilisateurs, pas seulement un filtre d'affichage. Il reste donc actif même en scope="all" + * (`investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)`), pour ne jamais + * remonter les investissements d'un autre utilisateur. + * - `invClauseOptional` (comptes, emprunts) : ces tables ont déjà leur propre `user_id` + * (`WHERE c.user_id = ?` déjà présent) — la frontière de propriété est donc assurée sans le + * filtre investisseur. En scope="all", ce filtre est donc omis entièrement (pas de + * IN-subquery) pour que le "Total actif" en vue "toute la famille" inclue aussi les + * comptes/emprunts sans détenteur assigné (investisseur_id NULL, ON DELETE SET NULL) — sans + * quoi le total en vue famille serait plus bas que la somme des vues individuelles, un + * comportement qui aurait semblé tout aussi cassé que le bug d'origine. + */ + +function userHasWorkspaceAccess(userId, type) { + const row = db.prepare(` + SELECT 1 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.type = ? + LIMIT 1 + `).get(userId, type); + return !!row; +} + +// Résout le scope investisseur de la requête — même contrat que GET /api/dashboard (cf. +// routes/dashboard.js, section "Résolution de l'investisseur cible"), avec deux variantes de +// clause selon la table cible (cf. commentaire d'en-tête, section "Scope investisseur"). +// Retourne soit { error: [status, body] } si le header est manquant/invalide (l'appelant doit +// alors répondre avec res.status(...).json(...) et s'arrêter), soit : +// - invClauseStrict(alias) / invParamsStrict — pour investissements/investissements_pe (pas de +// user_id propre : le filtre investisseur EST la frontière de propriété, reste actif même en +// scope="all"). +// - invClauseOptional(alias) / invParamsOptional — pour comptes/emprunts (ont déjà leur propre +// user_id : en scope="all", `invClauseOptional` retourne '1=1', aucun filtre investisseur +// supplémentaire — inclut aussi les lignes sans détenteur assigné). +// Un seul jeu de chaque paramètres, réutilisable pour toutes les requêtes de cette route +// puisqu'il ne dépend que du scope résolu une fois en tête de route. +function resolveInvestisseurScope(req, userId) { + if (req.query.scope === 'all') { + return { + invClauseStrict: (alias) => `${alias}.investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)`, + invParamsStrict: [userId], + invClauseOptional: () => '1=1', + invParamsOptional: [], + }; + } + const raw = req.header('X-Investisseur-Id'); + const invId = Number(raw); + if (!invId) { + return { error: [400, { 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 { error: [403, { error: 'Investisseur not found or not owned by user' }] }; + } + return { + invClauseStrict: (alias) => `${alias}.investisseur_id = ?`, + invParamsStrict: [invId], + invClauseOptional: (alias) => `${alias}.investisseur_id = ?`, + invParamsOptional: [invId], + }; +} + +// Regroupements dont le total ne vient pas de comptes.solde_eur mais du portefeuille réel du +// workspace dédié — cf. commentaire d'en-tête. Identifiés par nom (pas d'autre marqueur en base +// à ce jour) plutôt que par une colonne dédiée : seul cas d'usage pour l'instant, ne justifie +// pas une migration. +const PORTEFEUILLE_NOMS = new Set(['Crowdlending', 'Private Equity']); + +// "Capital investi" Crowdlending (29/09/26, suite à la remarque d'Olivier qui s'attendait à +// voir cette valeur dès la v1) — reprend EXACTEMENT la même métrique que le KPI "Capital +// investi" du Dashboard crowdlending (Dashboard.jsx : portfolio.encours + portfolio.en_defaut, +// cf. routes/dashboard.js) : capital actuellement engagé (statuts en_cours/en_retard/procedure +// uniquement — exclut rembourse/cloture), net des réinvestissements et remboursements de +// capital. Pas une nouvelle définition : un ré-affichage à l'identique. Scope investisseur +// (29/09/26, correctif) : agrégé sur le foyer entier ou un seul investisseur selon +// resolveInvestisseurScope, comme comptes/emprunts ailleurs dans ce fichier — plus +// systématiquement "tous les investisseurs de l'utilisateur". +const CL_CAPITAL_CASE = `CASE WHEN i.statut IN ('en_cours', 'en_retard', 'procedure') THEN + i.montant_investi + + COALESCE((SELECT SUM(rv.montant) FROM reinvestissements rv WHERE rv.investissement_id = i.id), 0) + - COALESCE((SELECT SUM(rb.capital) FROM remboursements rb WHERE rb.investissement_id = i.id AND rb.type = 'normal'), 0) + ELSE 0 END`; + +function crowdlendingCapitalInvesti(invClauseStrict, invParamsStrict) { + const row = db.prepare(` + SELECT COALESCE(SUM(${CL_CAPITAL_CASE}), 0) AS capital_investi + FROM investissements i + WHERE ${invClauseStrict('i')} + `).get(...invParamsStrict); + return row.capital_investi; +} + +// Détenteur agrégé pour une ligne groupée par plateforme (Crowdlending/PE) : GROUP_CONCAT +// DISTINCT ramène tous les détenteurs distincts séparés par virgule — un seul nom si tous les +// investissements de la plateforme partagent le même détenteur, "Plusieurs" s'ils divergent +// (foyer avec plusieurs investisseurs sur la même plateforme — ne peut plus se produire quand +// le scope est un investisseur unique, cf. resolveInvestisseurScope : seul son propre nom peut +// alors apparaître). +// +// Corrigé le 29/09/26 (suite, suite) : `inv.nom` seul, plus de concaténation avec `inv.prenom`. +// Olivier a signalé un second problème sur sa base réelle une fois la colonne Détenteur +// remplie ("Encore un pb sur l'affichage des détenteurs") : "Olivier Olivier CROGUENNEC" au +// lieu de "Olivier CROGUENNEC". Cause : investisseurs.nom stocke déjà le NOM COMPLET +// (prénom + nom de famille) pour un profil "famille" — cf. INSERT dans auth.js (création du +// profil principal à l'inscription : `nom = fullName`, `prenom` extrait séparément du même +// fullName) et FamilleEntreprises.jsx (le formulaire d'édition reconstruit `nom_famille` en +// retirant le prénom de `nom` : `m.nom.replace(m.prenom, '')`, preuve que `nom` contient déjà +// tout). Pour un profil "entreprise", `prenom` est NULL et `nom` est simplement la raison +// sociale. Dans les deux cas, `inv.nom` seul EST le nom d'affichage — le préfixer par +// `inv.prenom` (comme le faisaient encore `comptesLignesStmt`/`empruntsLignesStmt` plus bas, +// corrigés au même moment) dupliquait le prénom. Vérifié en lecture seule sur une copie locale +// de la base réelle d'Olivier (investisseurs.nom = "Olivier CROGUENNEC", "Capucine CROGUENNEC", +// "Marine CROGUENNEC" — jamais juste le nom de famille). +function toDetenteur(concat) { + if (!concat) return null; + const noms = [...new Set(concat.split(',').map(s => s.trim()).filter(Boolean))]; + if (noms.length === 0) return null; + if (noms.length === 1) return noms[0]; + return 'Plusieurs'; +} + +// Répartition du capital investi Crowdlending par plateforme (même métrique que +// crowdlendingCapitalInvesti ci-dessus, groupée) — demande Olivier 29/09/26 : "je m'attendrais +// à voir au moins le capital investi par plateforme". `detenteur` ajouté le 29/09/26 (suite) : +// Olivier a signalé que la colonne Détenteur restait vide sur ces lignes ("Cela n'a pas l'air +// de fonctionner pour les détenteurs") — chaque investissement porte bien un investisseur_id +// (détenteur), l'agrégation par plateforme se contentait jusque-là de ne pas le remonter. +// Scope investisseur (29/09/26, correctif) : `invClauseStrict`/`invParamsStrict` remplacent le +// filtrage fixe "tous les investisseurs de l'utilisateur", cf. resolveInvestisseurScope +// (variante stricte : investissements n'a pas de user_id propre). +function crowdlendingLignes(invClauseStrict, invParamsStrict) { + const rows = db.prepare(` + SELECT p.id AS id, p.nom AS label, + COALESCE(SUM(${CL_CAPITAL_CASE}), 0) AS valeur_eur, + COUNT(CASE WHEN i.statut IN ('en_cours', 'en_retard', 'procedure') THEN 1 END) AS nb_lignes, + GROUP_CONCAT(DISTINCT inv.nom) AS detenteurs_concat, + p.icone_filename AS icone_filename, + p.logo_filename AS logo_filename + FROM investissements i + JOIN plateformes p ON p.id = i.plateforme_id + JOIN investisseurs inv ON inv.id = i.investisseur_id + WHERE ${invClauseStrict('i')} + GROUP BY p.id, p.nom + HAVING valeur_eur > 0 + ORDER BY valeur_eur DESC + `).all(...invParamsStrict); + return rows.map(({ detenteurs_concat, ...r }) => ({ ...r, detenteur: toDetenteur(detenteurs_concat) })); +} + +// "Capital investi total" Private Equity — reprend EXACTEMENT la même métrique que le KPI +// "Capital investi total" du Dashboard PE (DashboardPe.jsx : somme de montant_investi sur tous +// les deals, tous statuts confondus — modèle PE différent du crowdlending, pas d'échéancier ni +// de notion d'encours net). Tous workspaces de type private_equity confondus (le type peut +// avoir plusieurs exemplaires, cf. adminWorkspaces.js). Scope investisseur (29/09/26, +// correctif) : cf. crowdlendingCapitalInvesti ci-dessus, même principe. +function privateEquityCapitalInvesti(invClauseStrict, invParamsStrict) { + const row = db.prepare(` + SELECT COALESCE(SUM(montant_investi), 0) AS capital_investi + FROM investissements_pe ipe + WHERE ${invClauseStrict('ipe')} + `).get(...invParamsStrict); + return row.capital_investi; +} + +// `detenteur` ajouté le 29/09/26 — même correction que crowdlendingLignes ci-dessus. Scope +// investisseur (29/09/26, correctif) : idem crowdlendingLignes (variante stricte). +function privateEquityLignes(invClauseStrict, invParamsStrict) { + const rows = db.prepare(` + SELECT p.id AS id, p.nom AS label, + COALESCE(SUM(ipe.montant_investi), 0) AS valeur_eur, + COUNT(*) AS nb_lignes, + GROUP_CONCAT(DISTINCT inv.nom) AS detenteurs_concat, + p.icone_filename AS icone_filename, + p.logo_filename AS logo_filename + FROM investissements_pe ipe + JOIN plateformes p ON p.id = ipe.plateforme_id + JOIN investisseurs inv ON inv.id = ipe.investisseur_id + WHERE ${invClauseStrict('ipe')} + GROUP BY p.id, p.nom + HAVING valeur_eur > 0 + ORDER BY valeur_eur DESC + `).all(...invParamsStrict); + return rows.map(({ detenteurs_concat, ...r }) => ({ ...r, detenteur: toDetenteur(detenteurs_concat) })); +} + +router.get('/', (req, res) => { + const userId = req.user.id; + + const scope = resolveInvestisseurScope(req, userId); + if (scope.error) { + const [status, body] = scope.error; + return res.status(status).json(body); + } + const { invClauseStrict, invParamsStrict, invClauseOptional, invParamsOptional } = scope; + + const roots = db.prepare(` + SELECT id, nom, parent_id, type, ordre_affichage + FROM regroupements_patrimoniaux + WHERE parent_id IS NULL + ORDER BY ordre_affichage, nom + `).all(); + const children = db.prepare(` + SELECT id, nom, parent_id, type, ordre_affichage + FROM regroupements_patrimoniaux + WHERE parent_id IS NOT NULL + ORDER BY ordre_affichage, nom + `).all(); + + // Scope investisseur (29/09/26, correctif) : `AND ${invClauseOptional('c')}` / + // `AND ${invClauseOptional('e')}` ajoutés aux 4 requêtes comptes/emprunts ci-dessous, params + // complétés par `...invParamsOptional` — cf. resolveInvestisseurScope et commentaire + // d'en-tête. Variante OPTIONNELLE (pas stricte) : comptes/emprunts ont déjà leur propre + // user_id, donc en scope="all" aucun filtre investisseur n'est ajouté (comptes.investisseur_id + // et emprunts.investisseur_id sont tous deux nullable, ON DELETE SET NULL, cf. db/index.js — + // un compte/emprunt sans détenteur assigné doit rester compté dans le "Total actif" de la vue + // "toute la famille"). En scope investisseur unique, la clause redevient stricte + // (`investisseur_id = ?`) — un compte sans détenteur assigné n'apparaît alors dans AUCUNE vue + // individuelle, ce qui est le comportement attendu. + const soldeStmt = db.prepare(` + SELECT + COALESCE(SUM(c.solde_eur), 0) AS total, + SUM(CASE WHEN c.solde_eur IS NOT NULL THEN 1 ELSE 0 END) AS nb_avec_solde, + SUM(CASE WHEN c.solde_eur IS NULL THEN 1 ELSE 0 END) AS nb_sans_solde + FROM comptes c + JOIN enveloppes_referentiel er ON er.id = c.enveloppe_id + WHERE c.user_id = ? AND er.regroupement_patrimonial_id = ? AND ${invClauseOptional('c')} + `); + // Lignes de détail "par compte" — établissement (institution du référentiel, sinon le champ + // texte libre banque) et détenteur, pour l'affichage dépliable (cf. commentaire d'en-tête). + // 30/09/26, retour d'Olivier ("La déclaration des comptes courant faite dans les paramètres + // n'apparaissent pas") : remonte désormais TOUS les comptes rattachés, y compris ceux sans + // solde saisi (valeur_eur alors NULL, cf. fmtEUR/fmtPct côté frontend) — un compte peut être + // "déclaré" (référencé, avec sa banque et son détenteur) sans que son solde soit suivi ; le + // filtre solde_eur IS NOT NULL les faisait disparaître entièrement de la liste, ce qui n'était + // pas ce qu'Olivier voulait. Les comptes sans solde sont triés en dernier. + const comptesLignesStmt = db.prepare(` + SELECT c.id AS id, c.nom AS label, + COALESCE(ir.nom, c.banque) AS sous_label, + c.solde_eur AS valeur_eur, + inv.nom AS detenteur, + ir.icone_filename AS icone_filename, + ir.logo_filename AS logo_filename + FROM comptes c + LEFT JOIN institutions_referentiel ir ON ir.id = c.institution_id + LEFT JOIN investisseurs inv ON inv.id = c.investisseur_id + JOIN enveloppes_referentiel er ON er.id = c.enveloppe_id + WHERE c.user_id = ? AND er.regroupement_patrimonial_id = ? AND ${invClauseOptional('c')} + ORDER BY (c.solde_eur IS NULL), c.solde_eur DESC + `); + const eligibleCountStmt = db.prepare(` + SELECT COUNT(*) AS n FROM enveloppes_referentiel + WHERE regroupement_patrimonial_id = ? AND eligible_compte = 1 + `); + const passifStmt = db.prepare(` + SELECT COALESCE(SUM(e.capital_restant_du), 0) AS total, COUNT(*) AS n + FROM emprunts e WHERE e.user_id = ? AND e.regroupement_patrimonial_id = ? AND ${invClauseOptional('e')} + `); + const empruntsLignesStmt = db.prepare(` + SELECT e.id AS id, e.nom AS label, + ir.nom AS sous_label, + e.capital_restant_du AS valeur_eur, + inv.nom AS detenteur, + ir.icone_filename AS icone_filename, + ir.logo_filename AS logo_filename + FROM emprunts e + LEFT JOIN institutions_referentiel ir ON ir.id = e.institution_id + LEFT JOIN investisseurs inv ON inv.id = e.investisseur_id + WHERE e.user_id = ? AND e.regroupement_patrimonial_id = ? AND ${invClauseOptional('e')} + ORDER BY e.capital_restant_du DESC + `); + + // Lignes patrimoniales directes (chantier "Visualisation d'un actif", relance Phase 3b, + // 30/09/26 — cf. commentaire de migration lignes_patrimoine/transactions_patrimoine dans + // db/index.js et claude/plan_visualisation_actif.md). Couvre le second chemin de + // rattachement déjà validé (enveloppe rattachée directement, sans compte intermédiaire) : + // une position patrimoniale pour les enveloppes sans aucun moyen de recevoir une valeur via + // comptes.solde_eur (Immobilier, Autres actifs, Crypto hors exchange...). `nb_transactions` + // et `valeur_calculee` permettent au code JS ci-dessous (buildNode) de choisir : valeur + // calculée depuis l'historique de transactions_patrimoine quand il existe, sinon repli sur + // la saisie directe `lp.valeur_eur` (cf. commentaire de migration pour la convention de + // signe : dépôt/achat ajoutent, retrait/vente retranchent, reevaluation est une variation + // signée directe). + const lignesPatrimoineStmt = db.prepare(` + SELECT lp.id AS id, lp.nom AS label, + ir.nom AS sous_label, + inv.nom AS detenteur, + ir.icone_filename AS icone_filename, + ir.logo_filename AS logo_filename, + lp.valeur_eur AS valeur_eur_saisie, + (SELECT COUNT(*) FROM transactions_patrimoine tp WHERE tp.ligne_patrimoine_id = lp.id) AS nb_transactions, + (SELECT COALESCE(SUM(CASE tp.type + WHEN 'depot' THEN tp.montant + WHEN 'achat' THEN tp.montant + WHEN 'reevaluation' THEN tp.montant + WHEN 'retrait' THEN -tp.montant + WHEN 'vente' THEN -tp.montant + END), 0) + FROM transactions_patrimoine tp WHERE tp.ligne_patrimoine_id = lp.id) AS valeur_calculee + FROM lignes_patrimoine lp + JOIN enveloppes_referentiel er ON er.id = lp.enveloppe_id + LEFT JOIN institutions_referentiel ir ON ir.id = lp.institution_id + LEFT JOIN investisseurs inv ON inv.id = lp.investisseur_id + WHERE lp.user_id = ? AND er.regroupement_patrimonial_id = ? AND ${invClauseOptional('lp')} + ORDER BY lp.nom + `); + + // Tri commun des lignes d'un regroupement "actif" une fois comptes + lignes_patrimoine + // fusionnées (chacune était déjà triée individuellement côté SQL, mais la fusion casse + // l'ordre global) : valeur décroissante, valeurs non renseignées (null) en dernier — même + // règle que ORDER BY (c.solde_eur IS NULL), c.solde_eur DESC ci-dessus. + function sortLignes(arr) { + return [...arr].sort((a, b) => { + if (a.valeur_eur == null && b.valeur_eur == null) return 0; + if (a.valeur_eur == null) return 1; + if (b.valeur_eur == null) return -1; + return b.valeur_eur - a.valeur_eur; + }); + } + + const accessCache = { + crowdlending: userHasWorkspaceAccess(userId, 'crowdlending'), + private_equity: userHasWorkspaceAccess(userId, 'private_equity'), + }; + + function buildNode(r) { + const isPortefeuille = PORTEFEUILLE_NOMS.has(r.nom); + const eligibleCount = eligibleCountStmt.get(r.id).n; + + let ownTotalEur = 0, nbAvecSolde = 0, nbSansSolde = 0, sourcePortefeuille = false, portefeuilleAVenir = false; + let lignes = []; + // Nombre de lignes_patrimoine rattachées à ce regroupement (quelle que soit leur valeur) — + // utilisé ci-dessous pour corriger structurellement_vide : une ligne_patrimoine réellement + // créée prouve que la saisie n'est plus structurellement impossible pour ce regroupement, + // même si aucune enveloppe n'y est eligible_compte=1. + let nbLignesPatrimoine = 0; + + if (r.nom === 'Crowdlending') { + if (accessCache.crowdlending) { + ownTotalEur = crowdlendingCapitalInvesti(invClauseStrict, invParamsStrict); + sourcePortefeuille = true; + lignes = crowdlendingLignes(invClauseStrict, invParamsStrict).map(x => ({ ...x, type: 'plateforme_cl' })); + } else { + portefeuilleAVenir = true; + } + } else if (r.nom === 'Private Equity') { + if (accessCache.private_equity) { + ownTotalEur = privateEquityCapitalInvesti(invClauseStrict, invParamsStrict); + sourcePortefeuille = true; + lignes = privateEquityLignes(invClauseStrict, invParamsStrict).map(x => ({ ...x, type: 'plateforme_pe' })); + } else { + portefeuilleAVenir = true; + } + } else if (r.type === 'actif') { + const s = soldeStmt.get(userId, r.id, ...invParamsOptional); + // `?? 0` (30/09/26, chantier "Visualisation d'un actif", corrigé au passage) : + // SUM(CASE ...) sur 0 ligne renvoie NULL en SQLite (pas de COALESCE dans soldeStmt pour + // ces 2 colonnes, contrairement à `total`) — jusqu'ici sans conséquence visible car un + // regroupement avec 0 compte est presque toujours structurellement_vide (et le frontend + // ne lit alors pas ces champs, cf. valeurRegroupement dans DashboardPatrimoine.jsx). + // Devient significatif avec lignes_patrimoine ci-dessous, qui incrémente ces mêmes + // compteurs pour des regroupements qui n'ont justement AUCUN compte (Immobilier, Autres + // actifs...) : sans ce repli, `nbSansSolde` resterait `null` au lieu de `0` dès qu'une + // ligne_patrimoine est ajoutée, et `node.nb_comptes_sans_solde === 0` (comparaison + // stricte côté frontend) échouerait à tort. + ownTotalEur = s.total; nbAvecSolde = s.nb_avec_solde ?? 0; nbSansSolde = s.nb_sans_solde ?? 0; + const comptesLignes = comptesLignesStmt.all(userId, r.id, ...invParamsOptional) + .map(x => ({ ...x, type: 'compte' })); + + const lignesPatRaw = lignesPatrimoineStmt.all(userId, r.id, ...invParamsOptional); + nbLignesPatrimoine = lignesPatRaw.length; + const lignesPat = lignesPatRaw.map(x => { + // Valeur calculée depuis l'historique de transactions quand il en existe au moins + // une, sinon repli sur la saisie directe (cf. commentaire de lignesPatrimoineStmt). + const valeur = x.nb_transactions > 0 ? x.valeur_calculee : x.valeur_eur_saisie; + if (valeur !== null && valeur !== undefined) { nbAvecSolde += 1; ownTotalEur += valeur; } + else { nbSansSolde += 1; } + return { + id: x.id, label: x.label, sous_label: x.sous_label, valeur_eur: valeur, + detenteur: x.detenteur, icone_filename: x.icone_filename, logo_filename: x.logo_filename, + type: 'ligne_patrimoine', + }; + }); + + lignes = sortLignes([...comptesLignes, ...lignesPat]); + } else { + const p = passifStmt.get(userId, r.id, ...invParamsOptional); + ownTotalEur = p.total; nbAvecSolde = p.n; nbSansSolde = 0; + lignes = empruntsLignesStmt.all(userId, r.id, ...invParamsOptional).map(x => ({ ...x, type: 'emprunt' })); + } + + const enfants = children.filter(ch => ch.parent_id === r.id).map(buildNode); + // Total ROULÉ (29/09/26, correctif) : propre total + total de chaque enfant — un enfant + // (ex. Private Equity) n'a lui-même pas d'enfant (hiérarchie à 2 niveaux max, cf. + // regroupementsPatrimoniaux.js), donc pas de risque de double-comptage plus profond. + const totalEur = ownTotalEur + enfants.reduce((s, e) => s + e.total_eur, 0); + // structurellement_vide ROULÉ (30/09/26, correctif) : un regroupement parent peut n'avoir + // aucune enveloppe éligible directement rattachée tout en ayant des enfants qui, eux, en + // ont (ex. "Comptes courants" une fois scindé en "Comptes courants rémunérés"/"non + // rémunérés", cf. claude/plan_regroupements_patrimoniaux.md) — dans ce cas le parent NE + // DOIT PAS être marqué structurellement vide, puisque son total roulé (ci-dessus) reste + // significatif. Calculé après `enfants` (donc après leurs propres structurellement_vide) : + // vide seulement si le nœud lui-même n'a pas d'enveloppe éligible ET que tous ses enfants + // (potentiellement aucun) sont eux-mêmes structurellement vides — un nœud sans enfant se + // comporte donc exactement comme avant (Immobilier, Autres actifs restent "—"). + // Correctif (30/09/26, chantier "Visualisation d'un actif") : une ligne_patrimoine + // réellement créée (nbLignesPatrimoine > 0) sort le regroupement de l'état + // structurellement_vide, même sans enveloppe eligible_compte=1 — la saisie n'est plus + // "impossible quoi que l'utilisateur saisisse" (cf. commentaire d'en-tête de ce fichier) + // puisqu'elle a effectivement eu lieu via ce second chemin. + const structurellementVide = !isPortefeuille && eligibleCount === 0 && nbLignesPatrimoine === 0 + && enfants.every(e => e.structurellement_vide); + + return { + id: r.id, + nom: r.nom, + type: r.type, + ordre_affichage: r.ordre_affichage, + total_eur: totalEur, + nb_comptes_avec_solde: nbAvecSolde, + nb_comptes_sans_solde: nbSansSolde, + structurellement_vide: structurellementVide, + portefeuille_a_venir: portefeuilleAVenir, + source_portefeuille: sourcePortefeuille, + lignes, + enfants, + }; + } + + const tree = roots.map(buildNode); + + // Les totaux racine sont désormais roulés (ils incluent déjà leurs enfants, cf. buildNode) — + // une simple somme des racines par type suffit, plus besoin de descendre récursivement dans + // l'arbre (la hiérarchie ne dépasse de toute façon pas 2 niveaux, cf. plus haut). + const totalActif = tree.filter(n => n.type === 'actif').reduce((s, n) => s + n.total_eur, 0); + const totalPassif = tree.filter(n => n.type === 'passif').reduce((s, n) => s + n.total_eur, 0); + + res.json({ + access: accessCache, + total_actif_eur: totalActif, + total_passif_eur: totalPassif, + net_eur: totalActif - totalPassif, + regroupements: tree, + }); +}); + +export default router; diff --git a/backend/src/routes/enveloppesReferentielPublic.js b/backend/src/routes/enveloppesReferentielPublic.js index 6566d65..75511b8 100644 --- a/backend/src/routes/enveloppesReferentielPublic.js +++ b/backend/src/routes/enveloppesReferentielPublic.js @@ -35,8 +35,14 @@ function attachCategories(rows) { /* GET /api/enveloppes-referentiel-public — liste complète (lecture seule) */ router.get('/', (_req, res) => { + // regroupement_patrimonial_id ajouté (30/09/26, chantier "Visualisation d'un actif") : le + // frontend en a besoin pour filtrer les enveloppes du sélecteur de lignes_patrimoine à celles + // du regroupement d'origine (Immobilier, Autres actifs...), cf. LignePatrimoineDetail.jsx. + // description ajoutée (30/09/26, chantier "Compléter mon patrimoine" — modale d'ajout + // rapide) : le sous-choix d'actifs de la modale affiche icône + intitulé + courte description + // pour chaque enveloppe, sur le même patron que la fiche produit du référentiel admin. const rows = db.prepare(` - SELECT id, nom, eligible_compte + SELECT id, nom, description, eligible_compte, regroupement_patrimonial_id FROM enveloppes_referentiel ORDER BY nom `).all(); diff --git a/backend/src/routes/lignesPatrimoine.js b/backend/src/routes/lignesPatrimoine.js new file mode 100644 index 0000000..7b705e3 --- /dev/null +++ b/backend/src/routes/lignesPatrimoine.js @@ -0,0 +1,109 @@ +import { Router } from 'express'; +import { z } from 'zod'; +import db from '../db/index.js'; +import { HttpError } from '../middleware/errorHandler.js'; +import { recordValorisation } from '../utils/valorisationsPatrimoine.js'; + +const router = Router(); + +/** + * routes/lignesPatrimoine.js — CRUD pour lignes_patrimoine (chantier "Visualisation d'un + * actif", relance Phase 3b, 30/09/26). Cf. commentaire de migration dans db/index.js pour le + * contexte complet et claude/plan_visualisation_actif.md pour le cadrage. + * + * Modelé sur routes/comptes.js (même structure Schema/GET/POST/PUT/DELETE, scope user_id). + * `enveloppe_id` non contraint à eligible_compte=0 côté Zod (même principe que + * comptes.js:enveloppe_id, non contraint à eligible_compte=1) — le filtrage se fait côté + * frontend, qui ne proposera cette création que depuis un regroupement structurellement vide + * (Immobilier, Autres actifs, Crypto hors exchange...), mais rien n'empêche techniquement une + * ligne_patrimoine sur une enveloppe eligible_compte=1 si un besoin apparaît plus tard. + */ + +const Schema = z.object({ + nom: z.string().min(1), + investisseur_id: z.number().int().positive().nullable().optional(), + enveloppe_id: z.number().int().positive(), + institution_id: z.number().int().positive().nullable().optional(), + valeur_eur: z.number().nullable().optional(), + notes: z.string().nullable().optional(), +}); + +router.get('/', (req, res) => { + const rows = db.prepare(` + SELECT lp.id, lp.nom, lp.investisseur_id, lp.enveloppe_id, lp.institution_id, + lp.valeur_eur, lp.notes, lp.created_at, lp.updated_at, + inv.nom AS investisseur_nom, + er.nom AS enveloppe_nom, er.regroupement_patrimonial_id AS regroupement_patrimonial_id, + ir.nom AS institution_nom, + ir.logo_filename AS institution_logo_filename, ir.icone_filename AS institution_icone_filename + FROM lignes_patrimoine lp + LEFT JOIN investisseurs inv ON inv.id = lp.investisseur_id + JOIN enveloppes_referentiel er ON er.id = lp.enveloppe_id + LEFT JOIN institutions_referentiel ir ON ir.id = lp.institution_id + WHERE lp.user_id = ? + ORDER BY lp.nom + `).all(req.user.id); + res.json(rows); +}); + +router.get('/:id', (req, res, next) => { + try { + const row = db.prepare(` + SELECT lp.id, lp.nom, lp.investisseur_id, lp.enveloppe_id, lp.institution_id, + lp.valeur_eur, lp.notes, lp.created_at, lp.updated_at, + inv.nom AS investisseur_nom, + er.nom AS enveloppe_nom, er.regroupement_patrimonial_id AS regroupement_patrimonial_id, + ir.nom AS institution_nom, + ir.logo_filename AS institution_logo_filename, ir.icone_filename AS institution_icone_filename + FROM lignes_patrimoine lp + LEFT JOIN investisseurs inv ON inv.id = lp.investisseur_id + JOIN enveloppes_referentiel er ON er.id = lp.enveloppe_id + LEFT JOIN institutions_referentiel ir ON ir.id = lp.institution_id + WHERE lp.id = ? AND lp.user_id = ? + `).get(req.params.id, req.user.id); + if (!row) throw new HttpError(404, 'Not found'); + res.json(row); + } catch (e) { next(e); } +}); + +router.post('/', (req, res, next) => { + try { + const body = Schema.parse(req.body); + const id = db.transaction(() => { + const r = db.prepare( + 'INSERT INTO lignes_patrimoine (user_id, investisseur_id, enveloppe_id, institution_id, nom, valeur_eur, notes) VALUES (?,?,?,?,?,?,?)' + ).run(req.user.id, body.investisseur_id ?? null, body.enveloppe_id, body.institution_id ?? null, body.nom, body.valeur_eur ?? null, body.notes ?? null); + recordValorisation({ lignePatrimoineId: r.lastInsertRowid, valeur: body.valeur_eur }); + return r.lastInsertRowid; + })(); + res.status(201).json({ id, ...body }); + } catch (e) { next(e); } +}); + +router.put('/:id', (req, res, next) => { + try { + const body = Schema.parse(req.body); + const changes = db.transaction(() => { + const result = db.prepare( + `UPDATE lignes_patrimoine SET investisseur_id=?, enveloppe_id=?, institution_id=?, nom=?, valeur_eur=?, notes=?, updated_at=datetime('now') WHERE id=? AND user_id=?` + ).run(body.investisseur_id ?? null, body.enveloppe_id, body.institution_id ?? null, body.nom, body.valeur_eur ?? null, body.notes ?? null, req.params.id, req.user.id).changes; + if (result > 0) recordValorisation({ lignePatrimoineId: Number(req.params.id), valeur: body.valeur_eur }); + return result; + })(); + if (changes === 0) throw new HttpError(404, 'Not found'); + res.json({ id: Number(req.params.id), ...body }); + } catch (e) { next(e); } +}); + +router.delete('/:id', (req, res, next) => { + try { + // ON DELETE CASCADE sur transactions_patrimoine.ligne_patrimoine_id (cf. migration, + // db/index.js) : supprimer une ligne_patrimoine supprime aussi ses transactions. + const r = db.prepare('DELETE FROM lignes_patrimoine WHERE id=? AND user_id=?') + .run(req.params.id, req.user.id); + if (r.changes === 0) throw new HttpError(404, 'Not found'); + res.status(204).end(); + } catch (e) { next(e); } +}); + +export default router; diff --git a/backend/src/routes/regroupementsPatrimoniauxPublic.js b/backend/src/routes/regroupementsPatrimoniauxPublic.js new file mode 100644 index 0000000..47f7e79 --- /dev/null +++ b/backend/src/routes/regroupementsPatrimoniauxPublic.js @@ -0,0 +1,30 @@ +import { Router } from 'express'; +import db from '../db/index.js'; + +/** + * routes/regroupementsPatrimoniauxPublic.js — lecture seule de la hiérarchie des + * "regroupements patrimoniaux" pour tous les utilisateurs authentifiés (pas requireAdmin), sur + * le même modèle que enveloppesReferentielPublic.js/institutionsReferentielPublic.js. + * + * Chantier "Compléter mon patrimoine" (30/09/26, demande Olivier) : la modale d'ajout rapide + * (bouton "+ Compléter mon patrimoine" de la topbar, cf. Layout.jsx/PatrimoineAjoutModal.jsx) + * a besoin de la liste complète des regroupements (racines + enfants) pour construire son + * premier écran (catégories) et déterminer, au second écran, quelles enveloppes rattacher à + * quelle catégorie cliquée (y compris ses éventuels enfants) — or regroupementsPatrimoniaux.js + * (CRUD admin) est monté sous requireAdmin, inaccessible à un utilisateur normal. + */ + +const router = Router(); +// Monté sous requireAuth (sans requireAdmin) — lecture seule, aucune route d'écriture ici. + +/* GET /api/regroupements-patrimoniaux-public — liste complète (lecture seule) */ +router.get('/', (_req, res) => { + const rows = db.prepare(` + SELECT id, nom, parent_id, type, ordre_affichage + FROM regroupements_patrimoniaux + ORDER BY ordre_affichage, nom + `).all(); + res.json(rows); +}); + +export default router; diff --git a/backend/src/routes/transactionsPatrimoine.js b/backend/src/routes/transactionsPatrimoine.js new file mode 100644 index 0000000..8465428 --- /dev/null +++ b/backend/src/routes/transactionsPatrimoine.js @@ -0,0 +1,130 @@ +import { Router } from 'express'; +import { z } from 'zod'; +import db from '../db/index.js'; +import { HttpError } from '../middleware/errorHandler.js'; + +const router = Router(); + +/** + * routes/transactionsPatrimoine.js — CRUD pour transactions_patrimoine (chantier + * "Visualisation d'un actif", relance Phase 3b, 30/09/26). Cf. commentaire de migration dans + * db/index.js pour le contexte complet (sur le patron de depots_retraits) et + * claude/plan_visualisation_actif.md pour le cadrage. + * + * Cible polymorphe : chaque transaction appartient à EXACTEMENT un compte OU une + * ligne_patrimoine (CHECK en base). Pas de user_id propre sur cette table — la frontière de + * propriété passe par la cible (comptes.user_id ou lignes_patrimoine.user_id), vérifiée + * explicitement ci-dessous à chaque accès (assertTargetOwnership / assertRowOwnership). + * + * Convention de signe sur `montant` (cf. commentaire de migration, db/index.js) : dépôt/achat + * et retrait/vente sont tous deux saisis en positif (le signe métier retrait/vente est appliqué + * au calcul de la valeur courante, pas stocké négatif) — validé ci-dessous côté Zod. reevaluation + * est une variation signée directe, peut être négative. + */ + +const Schema = z.object({ + compte_id: z.number().int().positive().nullable().optional(), + ligne_patrimoine_id: z.number().int().positive().nullable().optional(), + date_operation: z.string().min(1), + type: z.enum(['depot', 'retrait', 'achat', 'vente', 'reevaluation']), + montant: z.number(), + libelle: z.string().nullable().optional(), + notes: z.string().nullable().optional(), +}).superRefine((body, ctx) => { + const hasCompte = body.compte_id != null; + const hasLigne = body.ligne_patrimoine_id != null; + if (hasCompte === hasLigne) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Exactement un de compte_id ou ligne_patrimoine_id doit être renseigné', + path: ['compte_id'], + }); + } + if (body.type !== 'reevaluation' && body.montant < 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'montant doit être positif pour ce type de transaction (seul reevaluation accepte une valeur négative)', + path: ['montant'], + }); + } +}); + +// Vérifie que la cible (compte_id ou ligne_patrimoine_id) donnée dans le body appartient bien à +// l'utilisateur courant, avant insert/update. +function assertTargetOwnership(userId, body) { + if (body.compte_id != null) { + const row = db.prepare('SELECT 1 FROM comptes WHERE id = ? AND user_id = ?').get(body.compte_id, userId); + if (!row) throw new HttpError(403, 'Compte not found or not owned by user'); + } + if (body.ligne_patrimoine_id != null) { + const row = db.prepare('SELECT 1 FROM lignes_patrimoine WHERE id = ? AND user_id = ?').get(body.ligne_patrimoine_id, userId); + if (!row) throw new HttpError(403, 'Ligne patrimoine not found or not owned by user'); + } +} + +// Vérifie qu'une transaction EXISTANTE (par id) appartient à l'utilisateur courant, via sa +// cible actuelle — utilisé par PUT/DELETE avant toute modification. +function assertRowOwnership(userId, row) { + if (!row) throw new HttpError(404, 'Not found'); + if (row.compte_id != null) { + const c = db.prepare('SELECT 1 FROM comptes WHERE id = ? AND user_id = ?').get(row.compte_id, userId); + if (!c) throw new HttpError(403, 'Forbidden'); + } else if (row.ligne_patrimoine_id != null) { + const l = db.prepare('SELECT 1 FROM lignes_patrimoine WHERE id = ? AND user_id = ?').get(row.ligne_patrimoine_id, userId); + if (!l) throw new HttpError(403, 'Forbidden'); + } +} + +// GET /?compte_id=... ou GET /?ligne_patrimoine_id=... — liste des transactions d'UNE cible. +// Pas de listing global toutes cibles confondues (pas de user_id propre sur la table, cf. +// commentaire d'en-tête) : le frontend appelle toujours cette route depuis la page de gestion +// d'un actif précis, qui connaît déjà son compte_id ou ligne_patrimoine_id. +router.get('/', (req, res, next) => { + try { + const compteId = req.query.compte_id ? Number(req.query.compte_id) : null; + const ligneId = req.query.ligne_patrimoine_id ? Number(req.query.ligne_patrimoine_id) : null; + if (!compteId && !ligneId) throw new HttpError(400, 'compte_id ou ligne_patrimoine_id requis'); + if (compteId && ligneId) throw new HttpError(400, 'Un seul de compte_id ou ligne_patrimoine_id à la fois'); + assertTargetOwnership(req.user.id, { compte_id: compteId, ligne_patrimoine_id: ligneId }); + const rows = compteId + ? db.prepare('SELECT * FROM transactions_patrimoine WHERE compte_id = ? ORDER BY date_operation DESC, id DESC').all(compteId) + : db.prepare('SELECT * FROM transactions_patrimoine WHERE ligne_patrimoine_id = ? ORDER BY date_operation DESC, id DESC').all(ligneId); + res.json(rows); + } catch (e) { next(e); } +}); + +router.post('/', (req, res, next) => { + try { + const body = Schema.parse(req.body); + assertTargetOwnership(req.user.id, body); + const r = db.prepare( + 'INSERT INTO transactions_patrimoine (compte_id, ligne_patrimoine_id, date_operation, type, montant, libelle, notes) VALUES (?,?,?,?,?,?,?)' + ).run(body.compte_id ?? null, body.ligne_patrimoine_id ?? null, body.date_operation, body.type, body.montant, body.libelle ?? null, body.notes ?? null); + res.status(201).json({ id: r.lastInsertRowid, ...body }); + } catch (e) { next(e); } +}); + +router.put('/:id', (req, res, next) => { + try { + const existing = db.prepare('SELECT * FROM transactions_patrimoine WHERE id = ?').get(req.params.id); + assertRowOwnership(req.user.id, existing); + const body = Schema.parse(req.body); + assertTargetOwnership(req.user.id, body); + const changes = db.prepare( + `UPDATE transactions_patrimoine SET compte_id=?, ligne_patrimoine_id=?, date_operation=?, type=?, montant=?, libelle=?, notes=?, updated_at=datetime('now') WHERE id=?` + ).run(body.compte_id ?? null, body.ligne_patrimoine_id ?? null, body.date_operation, body.type, body.montant, body.libelle ?? null, body.notes ?? null, req.params.id).changes; + if (changes === 0) throw new HttpError(404, 'Not found'); + res.json({ id: Number(req.params.id), ...body }); + } catch (e) { next(e); } +}); + +router.delete('/:id', (req, res, next) => { + try { + const existing = db.prepare('SELECT * FROM transactions_patrimoine WHERE id = ?').get(req.params.id); + assertRowOwnership(req.user.id, existing); + db.prepare('DELETE FROM transactions_patrimoine WHERE id = ?').run(req.params.id); + res.status(204).end(); + } catch (e) { next(e); } +}); + +export default router; diff --git a/backend/src/routes/valorisationsPatrimoine.js b/backend/src/routes/valorisationsPatrimoine.js new file mode 100644 index 0000000..99df768 --- /dev/null +++ b/backend/src/routes/valorisationsPatrimoine.js @@ -0,0 +1,47 @@ +import { Router } from 'express'; +import db from '../db/index.js'; +import { HttpError } from '../middleware/errorHandler.js'; + +const router = Router(); + +/** + * routes/valorisationsPatrimoine.js — lecture seule pour valorisations_patrimoine (chantier + * "Historique de valeur", 30/09/26, demande Olivier). Cf. commentaire de migration dans + * db/index.js pour le contexte complet et utils/valorisationsPatrimoine.js pour l'écriture + * (automatique, sur solde_eur/valeur_eur — pas de route POST/PUT/DELETE ici : ces points sont + * dérivés des saisies sur comptes.js/lignesPatrimoine.js, jamais créés directement par + * l'utilisateur). + * + * Même patron de vérification de propriété que transactionsPatrimoine.js : pas de user_id + * propre sur cette table, la frontière passe par la cible (comptes.user_id ou + * lignes_patrimoine.user_id). + */ + +function assertTargetOwnership(userId, { compteId, ligneId }) { + if (compteId != null) { + const row = db.prepare('SELECT 1 FROM comptes WHERE id = ? AND user_id = ?').get(compteId, userId); + if (!row) throw new HttpError(403, 'Compte not found or not owned by user'); + } + if (ligneId != null) { + const row = db.prepare('SELECT 1 FROM lignes_patrimoine WHERE id = ? AND user_id = ?').get(ligneId, userId); + if (!row) throw new HttpError(403, 'Ligne patrimoine not found or not owned by user'); + } +} + +// GET /?compte_id=... ou GET /?ligne_patrimoine_id=... — historique de valeur d'UNE cible, +// ordre chronologique croissant (pratique pour tracer directement un graphique de progression). +router.get('/', (req, res, next) => { + try { + const compteId = req.query.compte_id ? Number(req.query.compte_id) : null; + const ligneId = req.query.ligne_patrimoine_id ? Number(req.query.ligne_patrimoine_id) : null; + if (!compteId && !ligneId) throw new HttpError(400, 'compte_id ou ligne_patrimoine_id requis'); + if (compteId && ligneId) throw new HttpError(400, 'Un seul de compte_id ou ligne_patrimoine_id à la fois'); + assertTargetOwnership(req.user.id, { compteId, ligneId }); + const rows = compteId + ? db.prepare('SELECT * FROM valorisations_patrimoine WHERE compte_id = ? ORDER BY date_valorisation ASC, id ASC').all(compteId) + : db.prepare('SELECT * FROM valorisations_patrimoine WHERE ligne_patrimoine_id = ? ORDER BY date_valorisation ASC, id ASC').all(ligneId); + res.json(rows); + } catch (e) { next(e); } +}); + +export default router; diff --git a/backend/src/server.js b/backend/src/server.js index 25c35dd..b0cb1e1 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -59,6 +59,7 @@ import supportsReferentielRouter from './routes/supportsReferentiel.js'; import empruntsRouter from './routes/emprunts.js'; import refCategoriesEnveloppesRouter from './routes/ref-categories-enveloppes.js'; import regroupementsPatrimoniauxRouter from './routes/regroupementsPatrimoniaux.js'; +import regroupementsPatrimoniauxPublicRouter from './routes/regroupementsPatrimoniauxPublic.js'; import refCategoriesRouter from './routes/ref-categories.js'; import refSecteursRouter from './routes/ref-secteurs.js'; import categoriesInvRouter from './routes/categories-inv.js'; @@ -72,6 +73,12 @@ import documentsRouter from './routes/documents.js'; import aiRouter from './routes/ai.js'; import workspacesRouter from './routes/workspaces.js'; import adminWorkspacesRouter from './routes/adminWorkspaces.js'; +import dashboardPatrimoineRouter from './routes/dashboardPatrimoine.js'; +// Chantier "Visualisation d'un actif" (relance Phase 3b, 30/09/26) — cf. commentaire de +// migration dans db/index.js et claude/plan_visualisation_actif.md. +import lignesPatrimoineRouter from './routes/lignesPatrimoine.js'; +import transactionsPatrimoineRouter from './routes/transactionsPatrimoine.js'; +import valorisationsPatrimoineRouter from './routes/valorisationsPatrimoine.js'; import { requireApiKey } from './middleware/apiKey.js'; import { swaggerSpec, swaggerUi } from './swagger.js'; import db from './db/index.js'; @@ -191,6 +198,7 @@ app.use('/api/enveloppes-referentiel-public', requireAuth, enveloppesReferentiel app.use('/api/supports-referentiel', requireAuth, requireAdmin, supportsReferentielRouter); app.use('/api/ref-categories-enveloppes', requireAuth, requireAdmin, refCategoriesEnveloppesRouter); app.use('/api/regroupements-patrimoniaux', requireAuth, requireAdmin, regroupementsPatrimoniauxRouter); +app.use('/api/regroupements-patrimoniaux-public', requireAuth, regroupementsPatrimoniauxPublicRouter); app.use('/api/ref-categories', requireAuth, requireAdmin, refCategoriesRouter); app.use('/api/ref-secteurs', requireAuth, requireAdmin, refSecteursRouter); app.use('/api/categories-inv', requireAuth, categoriesInvRouter); @@ -203,6 +211,10 @@ app.use('/api/documents', requireAuth, documentsRouter); app.use('/api/ai', requireAuth, aiRouter); app.use('/api/workspaces', requireAuth, workspacesRouter); app.use('/api/admin/workspaces', requireAuth, requireAdmin, adminWorkspacesRouter); +app.use('/api/dashboard-patrimoine', requireAuth, dashboardPatrimoineRouter); +app.use('/api/lignes-patrimoine', requireAuth, lignesPatrimoineRouter); +app.use('/api/transactions-patrimoine', requireAuth, transactionsPatrimoineRouter); +app.use('/api/valorisations-patrimoine', requireAuth, valorisationsPatrimoineRouter); app.use(errorHandler); diff --git a/backend/src/utils/valorisationsPatrimoine.js b/backend/src/utils/valorisationsPatrimoine.js new file mode 100644 index 0000000..fbc621d --- /dev/null +++ b/backend/src/utils/valorisationsPatrimoine.js @@ -0,0 +1,41 @@ +/** + * valorisationsPatrimoine.js — Helper d'enregistrement de l'historique de valeur + * (chantier "Historique de valeur", 30/09/26, demande Olivier). + * + * Chaque appel à recordValorisation() associe la valeur saisie manuellement sur un compte + * ou une ligne de patrimoine à la date du jour, dans la table valorisations_patrimoine + * (cf. migration dans db/index.js). Un seul point par jour et par actif : un second appel + * le même jour MET À JOUR le point existant plutôt que d'en créer un doublon (l'utilisateur + * qui corrige sa saisie plusieurs fois dans la journée ne doit pas polluer l'historique). + * + * N'écrit rien si valeur est null/undefined (un actif "non renseigné" ne produit pas de point). + * + * Usage : + * import { recordValorisation } from '../utils/valorisationsPatrimoine.js'; + * recordValorisation({ compteId: id, valeur: body.solde_eur }); + * recordValorisation({ lignePatrimoineId: id, valeur: body.valeur_eur }); + * + * À appeler à l'intérieur de la même transaction db.transaction(...) que l'INSERT/UPDATE + * du compte ou de la ligne, pour rester atomique avec la modification de l'actif. + */ + +import db from '../db/index.js'; + +export function recordValorisation({ compteId = null, lignePatrimoineId = null, valeur }) { + if (valeur === null || valeur === undefined) return; + + const updated = db.prepare(` + UPDATE valorisations_patrimoine + SET valeur = ?, created_at = datetime('now') + WHERE date_valorisation = date('now') + AND compte_id IS ? + AND ligne_patrimoine_id IS ? + `).run(valeur, compteId, lignePatrimoineId).changes; + + if (!updated) { + db.prepare(` + INSERT INTO valorisations_patrimoine (compte_id, ligne_patrimoine_id, date_valorisation, valeur) + VALUES (?, ?, date('now'), ?) + `).run(compteId, lignePatrimoineId, valeur); + } +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 41e4105..b8aea9d 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -11,6 +11,9 @@ 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 DashboardPatrimoine from './pages/DashboardPatrimoine.jsx'; +import LignePatrimoineDetail from './pages/LignePatrimoineDetail.jsx'; +import ActifApercu from './pages/ActifApercu.jsx'; import DepotsRetraits from './pages/DepotsRetraits.jsx'; import DepotsRetraitsSimple from './pages/DepotsRetraitsSimple.jsx'; import Investissements from './pages/Investissements.jsx'; @@ -95,10 +98,23 @@ function RequireWorkspace({ type, children }) { // portefeuille), à enrichir ensuite. function WorkspaceHome() { const { active } = useWorkspace() || {}; + if (active && active.type === 'patrimoine') return ; if (active && active.type !== 'crowdlending') return ; return ; } +// Chantier Workspace Patrimoine (28/09/26, demande Olivier) : contrairement au workspace +// Private Equity, Patrimoine n'a pas de modèle de page propre pour Plateformes / Investissements +// / Dépôts-Retraits / Remboursements / Frais / Fiscalité — c'est un dashboard consolidé en +// lecture seule (cadrage Olivier, AskUserQuestion "Un seul dashboard consolidé (recommandé pour +// démarrer)"), pas un 3e modèle de données à dupliquer partout comme PE l'a été. Les routeurs +// binaires ci-dessous (`type !== 'crowdlending'` → page PE) redirigent donc explicitement vers +// l'accueil pour ce type plutôt que de tomber silencieusement dans la branche PE (qui n'aurait +// aucun sens : /investissements-pe, /depots-retraits-pe, etc. n'existent pas pour Patrimoine). +function isPatrimoineActive(active) { + return !!(active && active.type === 'patrimoine'); +} + // 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 @@ -111,6 +127,7 @@ function WorkspaceHome() { // RequireWorkspace ci-dessus. function DepotsRetraitsRouter() { const { active } = useWorkspace() || {}; + if (isPatrimoineActive(active)) return ; if (active && active.type !== 'crowdlending') return ; return ; } @@ -124,6 +141,7 @@ function DepotsRetraitsRouter() { // un 3e type de workspace apparaît un jour. function RemboursementsRouter() { const { active } = useWorkspace() || {}; + if (isPatrimoineActive(active)) return ; if (active && active.type !== 'crowdlending') return ; return ; } @@ -135,6 +153,7 @@ function RemboursementsRouter() { // cf. project_workspaces_transformation.md. function InvestissementsRouter() { const { active } = useWorkspace() || {}; + if (isPatrimoineActive(active)) return ; if (active && active.type !== 'crowdlending') return ; return ; } @@ -144,6 +163,7 @@ function InvestissementsRouter() { // frais, documents), à enrichir ensuite (cf. InvestissementPeDetail.jsx). function InvestissementDetailRouter() { const { active } = useWorkspace() || {}; + if (isPatrimoineActive(active)) return ; if (active && active.type !== 'crowdlending') return ; return ; } @@ -153,6 +173,7 @@ function InvestissementDetailRouter() { // son propre indicateur "au moins un frais déclaré" (FraisOnly / FraisPeOnly ci-dessus). function FraisRouter() { const { active } = useWorkspace() || {}; + if (isPatrimoineActive(active)) return ; if (active && active.type !== 'crowdlending') return ; return ; } @@ -163,6 +184,7 @@ function FraisRouter() { // cf. project_workspaces_transformation.md). function PlateformesRouter() { const { active } = useWorkspace() || {}; + if (isPatrimoineActive(active)) return ; if (active && active.type !== 'crowdlending') return ; return ; } @@ -173,6 +195,7 @@ function PlateformesRouter() { // RequireWorkspace (elle l'était jusqu'ici, cf. route "taxreport" plus bas). function TaxReportRouter() { const { active } = useWorkspace() || {}; + if (isPatrimoineActive(active)) return ; if (active && active.type !== 'crowdlending') return ; return ; } @@ -228,6 +251,13 @@ export default function App() { } /> } /> } /> + {/* Chantier "Visualisation d'un actif" (relance Phase 3b, 30/09/26) — page de + gestion d'une ligne_patrimoine, atteinte depuis DashboardPatrimoine.jsx (cf. + claude/plan_visualisation_actif.md). */} + } /> + {/* Étape 3 (30/09/26) — aperçu en lecture pour un compte/emprunt existant, cf. + claude/plan_visualisation_actif.md. */} + } /> } /> {/* Assistant IA (26/09/26, demande Olivier) : n'est plus réservé au crowdlending — visible et accessible aussi depuis l'espace Private Equity (cf. Layout.jsx, nav diff --git a/frontend/src/components/Layout.jsx b/frontend/src/components/Layout.jsx index 5a61263..b241f42 100644 --- a/frontend/src/components/Layout.jsx +++ b/frontend/src/components/Layout.jsx @@ -9,6 +9,7 @@ import Logo from './Logo.jsx'; import UserMenu from './UserMenu.jsx'; import NotificationBell from './NotificationBell.jsx'; import AiAssistant from './AiAssistant.jsx'; +import PatrimoineAjoutModal from './PatrimoineAjoutModal.jsx'; /* ── Icônes nav ─────────────────────────────────────────────── */ const ICONS_BASE = '/api/icons-files/'; @@ -78,6 +79,11 @@ function ProjectSearch() { // d'interroger systematiquement l'endpoint crowdlending. Meme critere que le pattern // *Router() deja utilise ailleurs (cf. InvestissementDetailRouter, App.jsx). const isPe = !!(activeWorkspace && activeWorkspace.type !== 'crowdlending'); + // Chantier Workspace Patrimoine (28/09/26) : ce workspace n'a pas de table + // investissements/investissements_pe à interroger (dashboard consolidé en lecture seule, + // cf. App.jsx) — la recherche globale de projet reste donc vide plutôt que d'appeler un + // endpoint qui n'a pas de sens pour ce type. + const isPatrimoine = !!(activeWorkspace && activeWorkspace.type === 'patrimoine'); const [query, setQuery] = useState(''); const [allInv, setAllInv] = useState([]); const [open, setOpen] = useState(false); @@ -87,6 +93,7 @@ function ProjectSearch() { /* Chargement (ou rechargement) des investissements */ const loadInv = useCallback(async () => { + if (isPatrimoine) { setAllInv([]); return; } try { const scopeParams = activeView === 'all' ? { scope: 'all' } : {}; const endpoint = isPe ? '/investissements-pe' : '/investissements'; @@ -95,7 +102,7 @@ function ProjectSearch() { // commun "nom" pour eviter de dupliquer le filtre et le rendu ci-dessous. setAllInv(rows.map(r => ({ ...r, nom: isPe ? r.nom_deal : r.nom_projet }))); } catch {} - }, [activeView, isPe]); + }, [activeView, isPe, isPatrimoine]); useEffect(() => { loadInv(); }, [loadInv, activeId]); @@ -290,10 +297,17 @@ export default function Layout() { // 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; + // Chantier Workspace Patrimoine (28/09/26, demande Olivier) : Patrimoine est un dashboard + // consolidé en lecture seule, sans page Plateformes/Investissements/Dépôts-Retraits/ + // Remboursements/Frais/Fiscalité propre (cf. App.jsx, isPatrimoineActive) — ces liens de nav + // et les actions rapides de la topbar (Ajout Investissement, etc.) n'ont donc pas de sens ici + // et sont masqués. Tableau de bord et Assistant IA restent visibles (comme pour PE). + const isPatrimoine = !!(activeWorkspace && activeWorkspace.type === 'patrimoine'); const navigate = useNavigate(); const [navIcons, setNavIcons] = useState({}); const [aiEnabled, setAiEnabled] = useState(false); const [burgerOpen, setBurgerOpen] = useState(false); + const [patrimoineAjoutOpen, setPatrimoineAjoutOpen] = useState(false); const burgerRef = useRef(null); // Auto-collapse en mode portrait sur tablette (768–1200px) @@ -371,30 +385,34 @@ export default function Layout() { 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. */} - - Plateformes - - - Investissements - - - Dépôts / Retraits - - - Remboursements - - {showFrais && ( - - Frais - + {!isPatrimoine && ( + <> + + Plateformes + + + Investissements + + + Dépôts / Retraits + + + Remboursements + + {showFrais && ( + + Frais + + )} + {/* Fiscalité (20/09/26, chantier "Fiscalité PE") a désormais son équivalent PE + (TaxReportPe.jsx, TaxReportRouter dans App.jsx) — sortie du bloc isCrowdlendingActive + pour devenir visible quel que soit le workspace actif, même principe que + Plateformes/Investissements/Dépôts-Retraits/Remboursements ci-dessus. */} + + Fiscalité + + )} - {/* Fiscalité (20/09/26, chantier "Fiscalité PE") a désormais son équivalent PE - (TaxReportPe.jsx, TaxReportRouter dans App.jsx) — sortie du bloc isCrowdlendingActive - pour devenir visible quel que soit le workspace actif, même principe que - Plateformes/Investissements/Dépôts-Retraits/Remboursements ci-dessus. */} - - Fiscalité - {/* Assistant IA (26/09/26, demande Olivier : absent de l'espace "Private Equity") — même principe que les liens ci-dessus, sorti du bloc isCrowdlendingActive pour rester visible (toujours en dernière position) quel que soit le workspace actif ; @@ -418,15 +436,25 @@ export default function Layout() {
{/* Boutons visibles sur grand écran */} - - - + {!isPatrimoine && ( + <> + + + + + )} + + {isPatrimoine && ( + + )} {/* Bouton burger — visible sur tablette/mobile */}
@@ -444,16 +472,29 @@ export default function Layout() { Rechercher un projet -
- - - + {!isPatrimoine && ( + <> +
+ + + + + )} + + {isPatrimoine && ( + <> +
+ + + )}
Affichage @@ -491,6 +532,9 @@ export default function Layout() { + {isPatrimoine && ( + setPatrimoineAjoutOpen(false)} /> + )}
); } diff --git a/frontend/src/components/PatrimoineAjoutModal.jsx b/frontend/src/components/PatrimoineAjoutModal.jsx new file mode 100644 index 0000000..ab70190 --- /dev/null +++ b/frontend/src/components/PatrimoineAjoutModal.jsx @@ -0,0 +1,208 @@ +import { useEffect, useMemo, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { api } from '../api.js'; +import Modal from './Modal.jsx'; +import { useWorkspace } from '../context/WorkspaceContext.jsx'; + +/** + * components/PatrimoineAjoutModal.jsx èè modale "+ Compléter mon patrimoine" + * (chantier du 30/09/26, demande Olivier). écran 1 : catégories (regroupements + * patrimoniaux racine, icône + courte explication). écran 2 : sous-choix d'actifs + * (enveloppes du référentiel rattachées à la catégorie choisie + * + ses enfants), chacune menant à la saisie (nouvelle ligne de patrimoine). Cf. + * claude/plan_visualisation_actif.md pour le cadrage complet (3 décisions via + * AskUserQuestion le 30/09/26). + * + * Cas particuliers : + * - Crowdlending / Private Equity (enfants de "Comptes d'investissement") : tuiles dédiées + * qui basculent le workspace actif puis ouvrent /plateformes èè même logique que + * le clic sur une ligne du tableau de bord Patrimoine (DashboardPatrimoine.jsx). Grisées + * si le workspace correspondant n'est pas actif pour l'utilisateur. + * - Passif (Emprunts) : pas d'enveloppe de référentiel (table `emprunts` séparée) + * èè une seule tuile "Nouvel emprunt" renvoyant vers Paramétres > Mes emprunts, + * même principe que le bouton "Modifier dans Paramétres" de ActifApercu.jsx. + */ + +const I = ({ children }) => ( + +); + +const IconImmobilier = () => ; +const IconComptesCourants = () => ; +const IconEpargne = () => ; +const IconCrypto = () => ; +const IconInvestissement = () => ; +const IconAutresActifs = () => ; +const IconPassif = () => ; +const IconGenerique = () => ; + +const CATEGORY_META = { + 'Immobilier': { icon: IconImmobilier, description: "Résidences, locatif, SCPI, OPCI, SCI…" }, + 'Comptes courants': { icon: IconComptesCourants, description: "Comptes courants rémunérés ou non." }, + "Comptes d'épargne": { icon: IconEpargne, description: "Livrets réglementés ou bancaires, CEL, PEL…" }, + 'Crypto': { icon: IconCrypto, description: "Comptes exchange, wallets auto-conservés ou chez un tiers." }, + "Comptes d'investissement": { icon: IconInvestissement, description: "Assurance-vie, PER, PEE, Bourse, Crowdlending, Private Equity…" }, + 'Autres actifs': { icon: IconAutresActifs, description: "Métaux précieux, objets de collection, forêt, vigne…" }, + 'Passif': { icon: IconPassif, description: "Emprunts en cours." }, +}; +const DEFAULT_CATEGORY_META = { icon: IconGenerique, description: '' }; + +function CategoryIcon({ nom }) { + const Comp = (CATEGORY_META[nom] || DEFAULT_CATEGORY_META).icon; + return ; +} + +export default function PatrimoineAjoutModal({ open, onClose }) { + const navigate = useNavigate(); + const { activeWorkspaces, setActiveWorkspace } = useWorkspace() || {}; + + const [step, setStep] = useState('categorie'); // 'categorie' | 'actif' + const [selected, setSelected] = useState(null); + const [regroupements, setRegroupements] = useState(null); + const [enveloppes, setEnveloppes] = useState(null); + const [err, setErr] = useState(null); + + useEffect(() => { + if (!open) return; + setStep('categorie'); + setSelected(null); + setErr(null); + Promise.all([ + api.get('/regroupements-patrimoniaux-public'), + api.get('/enveloppes-referentiel-public'), + ]) + .then(([r, e]) => { setRegroupements(r); setEnveloppes(e); }) + .catch(ex => setErr(ex.message)); + }, [open]); + + const racines = useMemo( + () => (regroupements || []).filter(r => !r.parent_id).sort((a, b) => a.ordre_affichage - b.ordre_affichage), + [regroupements] + ); + + function handleClose() { onClose(); } + + // Important : on passe le regroupement_patrimonial_id propre de l'enveloppe (qui peut etre + // un enfant du regroupement affiche, ex. "Bourse" sous "Comptes d'investissement"), pas + // selected.id (le noeud racine cliqué a l'ecran 1) -- sinon le selecteur Enveloppe de + // LignePatrimoineDetail.jsx, filtre exactement sur regroupement_id, exclurait l'enveloppe + // pre-selectionnee (cf. son filtre enveloppesFiltrees). + function goToLigne(enveloppeId, regroupementId) { + navigate(`/patrimoine/lignes/nouveau?enveloppe_id=${enveloppeId}®roupement_id=${regroupementId}`); + handleClose(); + } + + function goToWorkspace(wsType) { + const target = (activeWorkspaces || []).find(w => w.type === wsType); + if (!target) return; + setActiveWorkspace(target.id); + navigate('/plateformes'); + handleClose(); + } + + function goToEmprunt() { + navigate('/settings?section=emprunts'); + handleClose(); + } + + // écran 2 : construit les tuiles "sous-choix d'actifs" pour la catégorie choisie. + const actifTiles = useMemo(() => { + if (!selected || !regroupements || !enveloppes) return []; + if (selected.nom === 'Passif') { + return [{ + key: 'emprunt', + nom: 'Nouvel emprunt', + description: "Montant emprunté, capital restant dû, taux, échéancier.", + onClick: goToEmprunt, + disabled: false, + }]; + } + const enfants = regroupements.filter(r => r.parent_id === selected.id); + const enfantsPlateforme = enfants.filter(r => r.nom === 'Crowdlending' || r.nom === 'Private Equity'); + const enfantsEnveloppe = enfants.filter(r => r.nom !== 'Crowdlending' && r.nom !== 'Private Equity'); + const idsEnveloppes = [selected.id, ...enfantsEnveloppe.map(r => r.id)]; + + const tuilesEnveloppes = enveloppes + .filter(e => idsEnveloppes.includes(e.regroupement_patrimonial_id)) + .sort((a, b) => a.nom.localeCompare(b.nom, 'fr')) + .map(e => ({ + key: `env-${e.id}`, + nom: e.nom, + description: e.description || '', + onClick: () => goToLigne(e.id, e.regroupement_patrimonial_id), + disabled: false, + })); + + const tuilesPlateforme = enfantsPlateforme.map(r => { + const wsType = r.nom === 'Crowdlending' ? 'crowdlending' : 'private_equity'; + const target = (activeWorkspaces || []).find(w => w.type === wsType); + return { + key: `ws-${r.id}`, + nom: r.nom, + description: target + ? `Géré depuis l'espace ${r.nom}.` + : `Espace ${r.nom} non activé pour votre compte.`, + onClick: () => goToWorkspace(wsType), + disabled: !target, + }; + }); + + return [...tuilesEnveloppes, ...tuilesPlateforme]; + }, [selected, regroupements, enveloppes, activeWorkspaces]); + + return ( + + + {selected?.nom} + + )} + > + {err &&
{err}
} + {!err && !regroupements &&
Chargement…
} + + {!err && regroupements && step === 'categorie' && ( +
+ {racines.map(r => ( + + ))} +
+ )} + + {!err && regroupements && step === 'actif' && ( +
+ {actifTiles.length === 0 && ( +
Aucun type d'actif disponible dans cette catégorie pour le moment.
+ )} + {actifTiles.map(t => ( + + ))} +
+ )} +
+ ); +} diff --git a/frontend/src/components/ValorisationPatrimoineChart.jsx b/frontend/src/components/ValorisationPatrimoineChart.jsx new file mode 100644 index 0000000..520ec03 --- /dev/null +++ b/frontend/src/components/ValorisationPatrimoineChart.jsx @@ -0,0 +1,305 @@ +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 (identiques à ValorisationPeChart.jsx) ────────────── */ +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 "Historique de valeur" d'un actif de patrimoine (compte ou ligne_patrimoine) — + * chantier "Historique de valeur" (30/09/26, demande Olivier). Reprend la structure SVG + * dessinée à la main de ValorisationPeChart.jsx (mêmes constantes W/H/PAD, mêmes plages + * RANGES, même tooltip au survol), mais avec une sémantique plus simple : un seul actif, une + * seule série de points {date, valeur} — pas d'agrégation multi-deals. `rows` est le résultat + * brut de GET /api/valorisations-patrimoine?compte_id=...|ligne_patrimoine_id=... (déjà trié + * ASC par la route sur date_valorisation, id). + * + * Contrairement à ValorisationPeChart (qui prend 0 comme valeur de repli "avant tout deal"), + * il n'y a pas de "0 avant le premier point" naturel ici : un actif non encore suivi n'avait pas + * une valeur de 0, il n'était simplement pas encore renseigné. Le repli utilisé est donc la + * valeur du point le plus proche disponible (avant, sinon le premier après). + */ +export default function ValorisationPatrimoineChart({ rows, title = 'Historique de valeur' }) { + const [range, setRange] = useState('TOUT'); + const [hover, setHover] = useState(null); + const svgRef = useRef(null); + + const todayStr = new Date().toISOString().slice(0, 10); + + /* ── 1. Normalisation de la série ── */ + const allPoints = useMemo(() => { + if (!rows?.length) return []; + return rows + .filter(r => r.date_valorisation && r.valeur != null) + .map(r => ({ date: r.date_valorisation.slice(0, 10), value: r.valeur })); + }, [rows]); + + const currentValue = allPoints.length ? allPoints[allPoints.length - 1].value : null; + const currentDate = allPoints.length ? allPoints[allPoints.length - 1].date : null; + + /* ── 2. Filtrage par plage ── */ + const points = useMemo(() => { + if (allPoints.length < 2) return []; + if (range === 'TOUT') { + const pts = [...allPoints]; + if (pts[pts.length - 1].date < todayStr) pts.push({ date: todayStr, value: pts[pts.length - 1].value }); + return pts; + } + const now = new Date(todayStr + '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 after = allPoints.filter(p => p.date >= fromStr); + const startV = before.length ? before[before.length - 1].value : (after.length ? after[0].value : allPoints[0].value); + const pts = after.length ? [{ date: fromStr, value: startV }, ...after] : [{ date: fromStr, value: startV }]; + if (pts[pts.length - 1].date < todayStr) pts.push({ date: todayStr, value: pts[pts.length - 1].value }); + return pts; + }, [allPoints, range, todayStr]); + + /* ── 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; + + // Moins de 2 points : rien à tracer (une droite a besoin de 2 points), on affiche juste la + // valeur connue et une note plutôt que rien du tout — meilleur que de masquer complètement + // le bloc, l'utilisateur comprend pourquoi il n'y a pas encore de courbe. + if (allPoints.length < 2) { + return ( +
+
+
+
+ {title} · {new Date(currentDate + 'T00:00:00').toLocaleDateString('fr-FR', { day: '2-digit', month: 'long', year: 'numeric' })} +
+
{fmtValueDisplay(currentValue)}
+
+
+

+ Un seul point d'historique pour l'instant — la courbe apparaîtra dès la prochaine mise à jour de la valeur. +

+
+ ); + } + + const displayDate = hover + ? new Date(hover.date + 'T00:00:00').toLocaleDateString('fr-FR', { day: '2-digit', month: 'long', year: 'numeric' }) + : new Date(currentDate + 'T00:00:00').toLocaleDateString('fr-FR', { day: '2-digit', month: 'long', year: 'numeric' }); + 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 ( +
+ + {/* ── En-tête ── */} +
+
+
{title} · {displayDate}
+
{displayValue}
+
+
+
+ {RANGES.map(r => ( + + ))} +
+
+
+ + {/* ── SVG ── */} + {xScale && ( + setHover(null)} + > + + + + + + + + + + + + + {/* Grille horizontale */} + {yTicks.map(({ v, y }) => ( + + + + {fmtK(v)} + + + ))} + + {/* Ligne zéro */} + {yZero && yZero > PAD.top && yZero < PAD.top + plotH && ( + + )} + + {/* Aire dégradée + courbe */} + + + + {/* Labels axe X */} + {xTicks.map((p, i) => { + const anchor = i === 0 ? 'start' : i === xTicks.length - 1 ? 'end' : 'middle'; + return ( + + {fmtAxisDate(p.date, range)} + + ); + })} + + {/* Hover : ligne verticale + point */} + {hover && ( + + + + + )} + + )} + + {/* ── Tooltip flottant ── */} + {hover && tooltipStyle && ( +
+
+ {tooltipDate} + {fmtValueDisplay(hover.value)} +
+
+ )} +
+ ); +} diff --git a/frontend/src/pages/ActifApercu.jsx b/frontend/src/pages/ActifApercu.jsx new file mode 100644 index 0000000..e338b9f --- /dev/null +++ b/frontend/src/pages/ActifApercu.jsx @@ -0,0 +1,167 @@ +import { useEffect, useState } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { api } from '../api.js'; +import { fmtEUR, fmtDate } from '../utils/format.js'; +import ValorisationPatrimoineChart from '../components/ValorisationPatrimoineChart.jsx'; + +/** + * pages/ActifApercu.jsx — page "Gestion d'un actif" (lecture) pour un compte ou un emprunt + * existant (chantier "Visualisation d'un actif", Étape 3, 30/09/26 — cf. + * claude/plan_visualisation_actif.md). Contrairement à LignePatrimoineDetail.jsx (lignes de + * patrimoine, CRUD complet), cette page est volontairement en lecture seule : décision #4 + * d'Olivier ("Il faut en faire un lien [...] cela reste limité au type de compte qui sont + * selectionnables par choix dans le référentiel") — Paramètres reste la source des infos + * structurelles pour un compte/emprunt, cette page s'y lie plutôt que de les dupliquer. + * + * Pas de section Transactions ici (Point à valider #1, réponse recommandée retenue : + * comptes.solde_eur reste en saisie directe en v1, les transactions sont réservées aux lignes + * de patrimoine) ni de section Positions (non pertinent pour un compte/emprunt). Pas de section + * Paramétrage éditable : un bouton "Modifier dans Paramètres" renvoie vers l'écran existant + * (Paramètres > Mes comptes / Mes emprunts). + * + * Route `/patrimoine/actif/:type/:id` avec `type` = "compte" | "emprunt" (cf. App.jsx). + * Atteinte depuis DashboardPatrimoine.jsx en cliquant une ligne de ce type. + */ + +const TYPE_META = { + compte: { label: 'Compte', settingsSection: 'comptes', listPath: '/comptes' }, + emprunt: { label: 'Emprunt', settingsSection: 'emprunts', listPath: '/emprunts' }, +}; + +export default function ActifApercu() { + const { type, id } = useParams(); + const navigate = useNavigate(); + const meta = TYPE_META[type]; + + const [row, setRow] = useState(null); + const [valorisations, setValorisations] = useState([]); + const [loading, setLoading] = useState(true); + const [err, setErr] = useState(null); + + useEffect(() => { + if (!meta) return; + setLoading(true); + setErr(null); + // Pas de GET /:id côté backend (comptes.js/emprunts.js n'exposent qu'un GET / — même + // patron que Paramètres > Mes comptes/Mes emprunts, qui chargent déjà toute la liste) : on + // réutilise la liste complète et on filtre côté client, sans ajouter de route backend pour + // un seul champ de lecture. + // + // Historique de valeur (30/09/26, demande Olivier) : uniquement pour les comptes — les + // emprunts sont explicitement hors périmètre de ce chantier (décision Olivier : "Non, + // actifs uniquement"). + Promise.all([ + api.get(meta.listPath), + type === 'compte' ? api.get('/valorisations-patrimoine', { compte_id: id }) : Promise.resolve([]), + ]) + .then(([rows, valo]) => { + const found = rows.find(r => String(r.id) === String(id)); + if (!found) throw new Error('Actif introuvable'); + setRow(found); + setValorisations(valo); + }) + .catch(e => setErr(e.message)) + .finally(() => setLoading(false)); + }, [type, id]); // eslint-disable-line react-hooks/exhaustive-deps + + if (!meta) return
Type d'actif inconnu.
; + if (loading) return

Chargement…

; + if (err) return
{err}
; + + return ( + <> +
+

+ + {row.nom} +

+
+ +
+
+ {type === 'compte' && ( +
+
Solde
+
+ {row.solde_eur == null + ? Non renseigné + : fmtEUR(row.solde_eur)} +
+
+ )} + {type === 'emprunt' && ( + <> +
+
Capital restant dû
+
{fmtEUR(row.capital_restant_du)}
+
+
+
Montant emprunté
+
{fmtEUR(row.montant_emprunte)}
+
+ {row.taux_nominal != null && ( +
+
Taux nominal
+
{row.taux_nominal} %
+
+ )} + {row.mensualite != null && ( +
+
Mensualité
+
{fmtEUR(row.mensualite)}
+
+ )} + {row.date_debut && ( +
+
Date de début
+
{fmtDate(row.date_debut)}
+
+ )} + {row.duree_mois != null && ( +
+
Durée
+
{row.duree_mois} mois
+
+ )} + + )} + {row.enveloppe_nom && ( +
+
Enveloppe
+
{row.enveloppe_nom}
+
+ )} + {(row.institution_nom || row.banque) && ( +
+
Établissement
+
{row.institution_nom || row.banque}
+
+ )} + {row.investisseur_nom && ( +
+
Détenteur
+
{row.investisseur_nom}
+
+ )} +
+
+ + {type === 'compte' && ( +
+ +
+ )} + +
+

Paramétrage

+

+ Les informations de ce {meta.label.toLowerCase()} (nom, établissement, enveloppe, détenteur{type === 'compte' ? ', solde' : ''}) + se gèrent depuis Paramètres — cette page n'en est qu'un aperçu, pour rester la source unique de vérité. +

+ +
+ + ); +} diff --git a/frontend/src/pages/DashboardPatrimoine.jsx b/frontend/src/pages/DashboardPatrimoine.jsx new file mode 100644 index 0000000..66a322c --- /dev/null +++ b/frontend/src/pages/DashboardPatrimoine.jsx @@ -0,0 +1,364 @@ +import { useEffect, useMemo, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import PageIcon from '../components/PageIcon.jsx'; +import { api } from '../api.js'; +import { useInvestisseur } from '../context/InvestisseurContext.jsx'; +import { useWorkspace } from '../context/WorkspaceContext.jsx'; +import { fmtEUR } from '../utils/format.js'; + +/** + * Tableau de bord du workspace "Patrimoine" (chantier du 28/09/26, demande Olivier — cf. + * claude/plan_workspace_patrimoine.md pour le cadrage complet). Vue consolidée en lecture + * seule par regroupement patrimonial (Admin > Regroupements — cf. AdminEnveloppes.jsx), un + * seul dashboard pour démarrer (décision Olivier, AskUserQuestion "Un seul dashboard + * consolidé (recommandé pour démarrer)"). Données : GET /api/dashboard-patrimoine (backend). + * + * Rendu en tableau (29/09/26, retour d'Olivier après avoir partagé des captures d'une autre + * application patrimoniale en référence : "rajouter un sorte d'organisation en tableau avec le + * nom, la répartition, le détenteur, la valeur, la +- values") — colonnes Nom / Répartition / + * Détenteur / Valeur / +/- value, lignes dépliables (regroupement → ses enfants + ses lignes de + * détail, cf. RegroupementRow). Remplace la précédente présentation en cartes empilées. + * + * Actif/Passif en onglets (30/09/26, retour d'Olivier avec capture d'écran à l'appui : "un + * système de tabulation avec actif et passif") — un seul tableau visible à la fois, via le + * composant .dr-tabs/.dr-tab déjà utilisé ailleurs dans l'app (Frais.jsx, TaxReport.jsx, + * Plateformes.jsx…). Remplace la présentation précédente où Actif et Passif étaient empilés + * l'un sous l'autre en permanence. L'onglet "Passifs" n'est même pas affiché s'il n'y a aucun + * regroupement de type passif (garde défensive — en pratique "Passif" existe toujours, seedé + * par db/index.js). + * + * "+/- value" est TOUJOURS affichée "—" avec une info-bulle explicative : contrairement aux + * captures de référence d'Olivier, l'app ne conserve aucun historique de valeur pour + * comptes.solde_eur ni emprunts.capital_restant_du (valeurs instantanées, écrasées à chaque + * saisie) — un vrai calcul de plus/moins-value nécessiterait de capturer un solde à chaque + * date, hors périmètre de ce chantier (cf. "Points ouverts", plan_workspace_patrimoine.md). + * + * "Répartition" est le poids de la ligne dans le total du tableau (total actif ou total passif + * selon la section) — une échelle commune à toutes les lignes, y compris les lignes de détail + * dépliées, pour rester comparable d'une ligne à l'autre quel que soit son niveau. + * + * "Détenteur" (29/09/26, retour d'Olivier, 3e puis 4e passe) n'est affiché QUE sur les lignes + * de détail (compte, emprunt, ou plateforme Crowdlending/Private Equity, cf. LigneRow) — jamais + * sur une ligne de regroupement (RegroupementRow, ex. "Comptes d'investissement", "Private + * Equity", "Crowdlending"), même quand toutes ses lignes partagent le même détenteur. Capture + * d'écran d'Olivier à l'appui, surlignant "Olivier CROGUENNEC" sur "Comptes d'investissement"/ + * "Private Equity" et "Plusieurs" sur "Crowdlending" : *"Je voudrais que tu garde le nom des + * détenteurs uniquement pour les produits ou les plateformes."* La cellule Détenteur d'une + * RegroupementRow était d'abord passée à "—" fixe (sans agrégation — l'ancienne fonction + * detenteurAgrege, qui remontait un nom unique ou "Plusieurs" au niveau du regroupement, a été + * retirée), puis Olivier a demandé une cellule vraiment vide plutôt qu'un tiret : *"Je veux un + * vide à la place pas des tirets."* La cellule est donc désormais vide (aucun texte, aucun + * tiret) sur toute RegroupementRow. + * + * Notions à distinguer (propagées depuis le backend, cf. dashboardPatrimoine.js) : + * - `structurellement_vide` : regroupement qui ne peut recevoir AUCUN solde saisi aujourd'hui + * (Immobilier, Autres actifs) — Valeur et Répartition affichées "—". + * - `portefeuille_a_venir` : Crowdlending/Private Equity sans accès utilisateur — ne devrait + * jamais s'afficher en pratique (filtré par le useMemo `regroupements` ci-dessous), gardé + * en repli défensif. + * - `source_portefeuille` : Crowdlending/Private Equity avec accès — Valeur = capital investi + * réel du workspace dédié. + */ + +function KpiCard({ title, value, note, color }) { + return ( +
+
{title}
+
{value}
+ {note &&
{note}
} +
+ ); +} + +function IconChevron({ open }) { + return ( + + ); +} + +const PLUS_MOINS_VALUE_TITLE = "Non disponible : l'application ne conserve pas d'historique de valeur pour ces montants (soldes et capital investi sont des valeurs instantanées)"; + +function fmtPct(value, total) { + // value peut être null (compte "déclaré" en Paramètres sans solde saisi, cf. LigneRow) — la + // coercition JS (null / total) donnerait 0,0 %, ce qui laisserait croire à un poids nul dans + // le patrimoine plutôt qu'à une valeur simplement inconnue. + if (value == null || !total) return '—'; + return `${(value / total * 100).toFixed(1).replace('.', ',')} %`; +} + +// Texte + info-bulle de la cellule Valeur d'une ligne de regroupement — mêmes cas particuliers +// que l'ancienne RegroupementValue (cf. commentaire d'en-tête), condensés pour tenir dans une +// cellule de tableau. +function valeurRegroupement(node) { + if (node.structurellement_vide) { + return { text: '—', title: 'Pas encore de saisie possible ici (à venir avec le suivi détaillé du patrimoine)' }; + } + if (node.portefeuille_a_venir) { + return { text: '—', title: 'Portefeuille géré dans le workspace dédié — non accessible pour votre compte' }; + } + if (node.source_portefeuille) { + return { text: fmtEUR(node.total_eur), title: `Capital investi — portefeuille du workspace ${node.nom}` }; + } + if (node.nb_comptes_avec_solde === 0 && node.nb_comptes_sans_solde === 0) { + return { text: fmtEUR(node.total_eur), title: 'Aucun compte rattaché' }; + } + const detail = `${node.nb_comptes_avec_solde} compte${node.nb_comptes_avec_solde > 1 ? 's' : ''} renseigné${node.nb_comptes_avec_solde > 1 ? 's' : ''}` + + (node.nb_comptes_sans_solde > 0 ? ` · ${node.nb_comptes_sans_solde} sans solde saisi` : ''); + return { text: fmtEUR(node.total_eur), title: detail }; +} + +// Icône établissement/plateforme devant chaque ligne de détail (30/09/26, demande d'Olivier, +// capture à l'appui — une ligne "LDD Solidaire - Capucine" avec le logo Crédit Agricole devant +// le libellé) : "Peux-tu mettre en place des icones (plateformes ou institutions financières) +// devant les actifs". Le backend (dashboardPatrimoine.js) expose désormais icone_filename/ +// logo_filename sur chaque ligne — institutions_referentiel pour les comptes/emprunts, +// plateformes pour Crowdlending/Private Equity — servis au même endroit que partout ailleurs +// dans l'app (/api/logos/:filename, cf. ComptesSection.jsx/PlatSelector.jsx) : icône carrée +// dédiée en priorité, repli sur le logo large si aucune icône n'a été téléversée, rien si ni +// l'un ni l'autre (compte en texte libre sans institution liée, plateforme sans logo). +const API_BASE = (import.meta.env.VITE_API_URL || '/api').replace(/\/api$/, ''); +function ligneIconUrl(ligne) { + const filename = ligne.icone_filename || ligne.logo_filename; + return filename ? `${API_BASE}/api/logos/${filename}` : null; +} + +// Ligne de détail (feuille — compte, emprunt, ou plateforme Crowdlending/Private Equity). +// +// 30/09/26, retour d'Olivier ("La déclaration des comptes courant faite dans les paramètres +// n'apparaissent pas") : un compte peut désormais apparaître ici avec `valeur_eur: null` — il a +// été "déclaré" (référencé) dans Paramètres, avec sa banque et son détenteur, sans qu'un solde +// ait été saisi. But initial de la déclaration selon Olivier : "indiquer d'où venait l'argent et +// où les remboursements pouvaient atterrir", pas nécessairement suivre un montant. On affiche +// alors "Non renseigné" plutôt qu'une cellule vide (fmtEUR(null) retourne '') ou trompeuse +// (fmtPct calculerait sinon 0,0 % via la coercition JS null → 0). +function LigneRow({ ligne, depth, grandTotal }) { + const navigate = useNavigate(); + const sansValeur = ligne.valeur_eur == null; + const icon = ligneIconUrl(ligne); + // Étape 3 (30/09/26) — compte/emprunt ouvrent désormais ActifApercu.jsx (lecture seule, + // lien vers Paramètres) en plus des lignes_patrimoine (page CRUD complète). Les plateformes + // Crowdlending/Private Equity (type plateforme_cl/plateforme_pe) restent non cliquables ici + // — Étape 4, non implementée (cf. claude/plan_visualisation_actif.md). + const { activeWorkspaces, setActiveWorkspace } = useWorkspace() || {}; + const isPlateforme = ligne.type === 'plateforme_cl' || ligne.type === 'plateforme_pe'; + const clickable = ligne.type === 'ligne_patrimoine' || ligne.type === 'compte' || ligne.type === 'emprunt' || isPlateforme; + + function handleClick() { + if (ligne.type === 'ligne_patrimoine') { navigate(`/patrimoine/lignes/${ligne.id}`); return; } + if (ligne.type === 'compte' || ligne.type === 'emprunt') { navigate(`/patrimoine/actif/${ligne.type}/${ligne.id}`); return; } + if (isPlateforme) { + const wsType = ligne.type === 'plateforme_cl' ? 'crowdlending' : 'private_equity'; + const target = (activeWorkspaces || []).find(w => w.type === wsType); + // Workspace accorde (access.*) mais desactive par l'utilisateur (Parametres > Mes + // espaces de travail) : pas dans activeWorkspaces, donc pas de bascule possible ici - + // cette ligne n'aurait de toute facon pas du apparaitre (cf. access.crowdlending/ + // private_equity cote backend), garde defensive plutot qu'un crash silencieux. + if (!target) return; + setActiveWorkspace(target.id); + navigate('/plateformes'); + } + } + + return ( + + +
+ {icon && ( + + )} +
+
{ligne.label}
+
+ {ligne.sous_label || '—'} + {ligne.nb_lignes > 1 && ` · ${ligne.nb_lignes} investissements`} +
+
+
+ + {fmtPct(ligne.valeur_eur, grandTotal)} + {ligne.detenteur || '—'} + + {sansValeur + ? Non renseigné + : fmtEUR(ligne.valeur_eur)} + + — + + ); +} + +// Bouton "+" par regroupement retire (chantier "Completer mon patrimoine", 30/09/26 +// (suite), demande Olivier) : faisait doublon avec le bouton "+ Completer mon patrimoine" de +// la topbar (PatrimoineAjoutModal.jsx), qui couvre desormais tous les regroupements (pas +// seulement les structurellement_vide) et propose en plus le sous-choix d'actifs par icone. + +// Ligne de regroupement (racine ou enfant, ex. Private Equity sous Comptes d'investissement) — +// dépliable dès qu'elle a au moins une ligne de détail ou un enfant. +function RegroupementRow({ node, depth, grandTotal, expanded, toggle }) { + const key = `r:${node.id}`; + const isOpen = expanded.has(key); + const hasChildren = (node.lignes && node.lignes.length > 0) || (node.enfants && node.enfants.length > 0); + const valeur = valeurRegroupement(node); + const pctText = (node.structurellement_vide || node.portefeuille_a_venir) ? '—' : fmtPct(node.total_eur, grandTotal); + + return ( + <> + hasChildren && toggle(key)}> + + + {hasChildren ? : } + {node.nom} + + + {pctText} + + {valeur.text} + — + + {isOpen && node.lignes.map(l => ( + + ))} + {isOpen && node.enfants.map(child => ( + + ))} + + ); +} + +function RegroupementsTable({ nodes, grandTotal, expanded, toggle }) { + return ( +
+ + + + + + + + + + + + {nodes.map(node => ( + + ))} + +
NomRépartitionDétenteurValeur+/- value
+
+ ); +} + +export default function DashboardPatrimoine() { + const { activeId, activeView } = useInvestisseur(); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [err, setErr] = useState(null); + const [expanded, setExpanded] = useState(() => new Set()); + // Onglets Actifs/Passifs (30/09/26, demande Olivier avec capture d'écran à l'appui : "un + // système de tabulation avec actif et passif") — même composant .dr-tabs/.dr-tab que le + // reste de l'app (Frais.jsx, TaxReport.jsx, Plateformes.jsx…), état local simple comme sur + // Frais.jsx (pas de persistance URL/localStorage nécessaire ici, un seul dashboard). + const [activeTab, setActiveTab] = useState('actifs'); + + // Filtre par détenteur actif (30/09/26, retour d'Olivier : "La sélection d'un détenteur ne + // semble pas filtrer le patrimoine") — même convention que Dashboard.jsx/DashboardPe.jsx : + // activeView === 'all' ("toute la famille") envoie ?scope=all, sinon le header + // X-Investisseur-Id (ajouté automatiquement par api.js depuis activeId) filtre sur le seul + // investisseur actif. On refetch à chaque changement de profil actif. + useEffect(() => { + if (!activeId && activeView !== 'all') return; + const scopeParams = activeView === 'all' ? { scope: 'all' } : undefined; + setLoading(true); + setErr(null); + api.get('/dashboard-patrimoine', scopeParams) + .then(setData) + .catch(e => setErr(e.message)) + .finally(() => setLoading(false)); + }, [activeId, activeView]); + + const toggle = (key) => { + setExpanded(prev => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); else next.add(key); + return next; + }); + }; + + // Filtre "respect de l'accès utilisateur" (décision Olivier) : Crowdlending / Private Equity + // ne sont même pas affichés si l'utilisateur n'a pas accès au workspace correspondant — cf. + // backend routes/dashboardPatrimoine.js (access.crowdlending / access.private_equity). + // + // 30/09/26 : Crowdlending a été réintégré comme sous-regroupement de "Comptes + // d'investissement" (même statut que Private Equity, cf. claude/ + // plan_regroupements_patrimoniaux.md) — il n'apparaît donc plus jamais au niveau racine de + // `data.regroupements`, seulement dans les `enfants` de "Comptes d'investissement". Le filtre + // par accès porte désormais uniquement sur les enfants, pour les deux noms. + const regroupements = useMemo(() => { + if (!data) return []; + return data.regroupements.map(r => ({ + ...r, + enfants: r.enfants.filter(c => + (c.nom !== 'Private Equity' || data.access.private_equity) && + (c.nom !== 'Crowdlending' || data.access.crowdlending) + ), + })); + }, [data]); + + const actifs = regroupements.filter(r => r.type === 'actif'); + const passifs = regroupements.filter(r => r.type === 'passif'); + + return ( + <> +

Tableau de bord — Patrimoine

+ + {loading &&
Chargement…
} + {err &&
{err}
} + + {!loading && !err && data && ( + <> +
+ + + = 0 ? 'var(--success)' : 'var(--danger)'} + /> +
+ +
+ + {passifs.length > 0 && ( + + )} +
+ + {activeTab === 'actifs' || passifs.length === 0 ? ( + + ) : ( + + )} + +

+ Les soldes affichés proviennent de la saisie manuelle sur vos comptes (Paramètres + > Mes comptes courants), à l'exception de Crowdlending et Private Equity, qui + reprennent le capital investi déjà affiché dans leur propre workspace. Les + regroupements marqués « — » ne sont pas encore couverts par cette vue consolidée. + La colonne « +/- value » n'est pas encore disponible (aucun historique de valeur + n'est conservé aujourd'hui). +

+ + )} + + ); +} diff --git a/frontend/src/pages/LignePatrimoineDetail.jsx b/frontend/src/pages/LignePatrimoineDetail.jsx new file mode 100644 index 0000000..641d7ea --- /dev/null +++ b/frontend/src/pages/LignePatrimoineDetail.jsx @@ -0,0 +1,466 @@ +import { useEffect, useMemo, useState } from 'react'; +import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; +import { api } from '../api.js'; +import Modal from '../components/Modal.jsx'; +import ConfirmModal from '../components/ConfirmModal.jsx'; +import ValorisationPatrimoineChart from '../components/ValorisationPatrimoineChart.jsx'; +import { fmtEUR, fmtDate, today } from '../utils/format.js'; +import { useAsyncAction } from '../hooks/useAsyncAction.js'; + +/** + * pages/LignePatrimoineDetail.jsx — page "Gestion d'un actif" pour une ligne_patrimoine + * (chantier "Visualisation d'un actif", relance Phase 3b, 30/09/26 — cf. demande d'Olivier : + * "j'aimerais travailler sur une notion de visualisation d'un actif [...] avoir un apercu de + * l'actif (graphique, KPI, etc..), [...] pouvoir rajouter la notion de transactions et une + * notion de parametrages" — cadrage complet dans claude/plan_visualisation_actif.md). + * + * Route `/patrimoine/lignes/:id` (`id` = "nouveau" pour la creation, cf. App.jsx) — atteinte + * depuis DashboardPatrimoine.jsx en cliquant une ligne de type `ligne_patrimoine`, ou le bouton + * "+" d'un regroupement structurellement vide (Immobilier, Autres actifs...), qui passe + * `?enveloppe_id=X®roupement_id=Y` pour pre-remplir/filtrer le selecteur Enveloppe. Le + * bouton retour ramene toujours au dashboard patrimoine (route `/`, cf. WorkspaceHome). + * + * 3 sections, comme demande ("un apercu de l'actif [...], visualiser les positions [...] si + * pertinent, [...] transactions et [...] parametrages") : Apercu (valeur actuelle + contexte), + * Transactions (uniquement en edition — une ligne doit exister avant d'avoir des + * transactions), Parametrage (le formulaire de la ligne elle-meme, reutilise pour la creation). + * Pas de section "Positions" ici : les positions (=lignes d'investissement, cf. demande + * d'Olivier) concernent Crowdlending/Private Equity, gerees dans leurs workspaces dedies — hors + * perimetre d'une ligne_patrimoine, qui est une position unique par construction. + * + * Convention de signe reprise de la migration transactions_patrimoine (db/index.js) : + * depot/achat ajoutent, retrait/vente retranchent (saisis en positif dans le formulaire, comme + * valide cote Zod backend), reevaluation est une variation signee directe (peut etre negative). + */ + +const TYPE_LABELS = { depot: 'Dépôt', retrait: 'Retrait', achat: 'Achat', vente: 'Vente', reevaluation: 'Réévaluation' }; + +function IcoDots() { + return ; +} +function IcoEdit() { + return ; +} +function IcoTrash() { + return ; +} +function MenuBtn({ onClick, icon, label, danger = false }) { + return ( + + ); +} + +function emptyTransForm() { + return { type: 'depot', montant: '', date_operation: today(), libelle: '', notes: '' }; +} +function emptyLigneForm(enveloppeIdPrefill) { + return { nom: '', enveloppe_id: enveloppeIdPrefill || '', institution_id: '', investisseur_id: '', valeur_eur: '', notes: '' }; +} + +// Valeur courante calculee depuis un historique de transactions — meme formule que +// lignesPatrimoineStmt cote backend (dashboardPatrimoine.js) : depot/achat ajoutent, +// retrait/vente retranchent, reevaluation est une variation signee directe. +function valeurDepuisTransactions(transactions) { + if (transactions.length === 0) return null; + return transactions.reduce((s, t) => { + if (t.type === 'depot' || t.type === 'achat') return s + t.montant; + if (t.type === 'retrait' || t.type === 'vente') return s - t.montant; + return s + t.montant; // reevaluation + }, 0); +} + +// Chantier "Historique de valeur" (30/09/26, demande Olivier) : quand une ligne a des +// transactions, sa valeur "officielle" est le cumul des transactions (valeurDepuisTransactions +// ci-dessus), pas la table valorisations_patrimoine (qui ne suit que la saisie directe +// valeur_eur, jamais mise a jour une fois que des transactions existent — cf. +// utils/valorisationsPatrimoine.js et lignesPatrimoine.js). Le graphique doit donc tracer CETTE +// serie cumulative-la, pas l'historique de valorisations_patrimoine, pour rester coherent avec +// la valeur actuelle affichee au-dessus. On reconstruit une serie au meme format que +// valorisations_patrimoine ({date_valorisation, valeur}) pour reutiliser +// ValorisationPatrimoineChart sans lui ajouter un second format d'entree. +function seriesDepuisTransactions(transactions) { + if (!transactions.length) return []; + const asc = [...transactions].sort((a, b) => a.date_operation.localeCompare(b.date_operation) || a.id - b.id); + let cum = 0; + const out = []; + for (const t of asc) { + if (t.type === 'depot' || t.type === 'achat') cum += t.montant; + else if (t.type === 'retrait' || t.type === 'vente') cum -= t.montant; + else cum += t.montant; // reevaluation + const d = t.date_operation.slice(0, 10); + if (out.length && out[out.length - 1].date_valorisation === d) { + out[out.length - 1].valeur = cum; + } else { + out.push({ date_valorisation: d, valeur: cum }); + } + } + return out; +} + +export default function LignePatrimoineDetail() { + const { id } = useParams(); + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + const isNew = id === 'nouveau'; + + const [ligne, setLigne] = useState(null); + const [transactions, setTransactions] = useState([]); + const [valorisations, setValorisations] = useState([]); + const [enveloppes, setEnveloppes] = useState([]); + const [institutions, setInstitutions] = useState([]); + const [investisseurs, setInvestisseurs] = useState([]); + const [loading, setLoading] = useState(true); + const [err, setErr] = useState(null); + + const [form, setForm] = useState(() => emptyLigneForm(searchParams.get('enveloppe_id'))); + + const { run, pending } = useAsyncAction(); + const { run: runTrans, pending: pendingTrans } = useAsyncAction(); + + const [transModalOpen, setTransModalOpen] = useState(false); + const [transForm, setTransForm] = useState(emptyTransForm()); + const [editingTransId, setEditingTransId] = useState(null); + const [deleteTransId, setDeleteTransId] = useState(null); + const [deleteLigneConfirm, setDeleteLigneConfirm] = useState(false); + const [openMenu, setOpenMenu] = useState(null); + const [saveErr, setSaveErr] = useState(null); + + useEffect(() => { + setLoading(true); + setErr(null); + Promise.all([ + api.get('/enveloppes-referentiel-public'), + api.get('/institutions-referentiel-public'), + api.get('/investisseurs'), + isNew ? Promise.resolve(null) : api.get(`/lignes-patrimoine/${id}`), + isNew ? Promise.resolve([]) : api.get('/transactions-patrimoine', { ligne_patrimoine_id: id }), + isNew ? Promise.resolve([]) : api.get('/valorisations-patrimoine', { ligne_patrimoine_id: id }), + ]) + .then(([env, inst, invs, lp, trans, valo]) => { + setEnveloppes(env); + setInstitutions(inst); + setInvestisseurs(invs); + if (lp) { + setLigne(lp); + setForm({ + nom: lp.nom, + enveloppe_id: String(lp.enveloppe_id), + institution_id: lp.institution_id ? String(lp.institution_id) : '', + investisseur_id: lp.investisseur_id ? String(lp.investisseur_id) : '', + valeur_eur: lp.valeur_eur ?? '', + notes: lp.notes || '', + }); + } + setTransactions(trans || []); + setValorisations(valo || []); + }) + .catch(e => setErr(e.message)) + .finally(() => setLoading(false)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [id, isNew]); + + const nbTransactions = transactions.length; + const valeurCalculee = useMemo(() => valeurDepuisTransactions(transactions), [transactions]); + const valeurActuelle = nbTransactions > 0 + ? valeurCalculee + : (form.valeur_eur !== '' ? Number(form.valeur_eur) : null); + const chartRows = useMemo( + () => (nbTransactions > 0 ? seriesDepuisTransactions(transactions) : valorisations), + [nbTransactions, transactions, valorisations] + ); + + // Filtre le selecteur Enveloppe au regroupement d'origine (query param a la creation, sinon + // celui de la ligne deja rattachee) — non contraint en base ni cote Zod (cf. commentaire + // d'en-tete de lignesPatrimoine.js), filtrage purement cote frontend, meme principe que le + // selecteur Enveloppe de Parametres > Mes comptes (restreint a eligible_compte=1, ici + // restreint au regroupement d'origine). + const regroupementId = searchParams.get('regroupement_id') || ligne?.regroupement_patrimonial_id; + const enveloppesFiltrees = useMemo(() => { + if (!regroupementId) return enveloppes; + return enveloppes.filter(e => String(e.regroupement_patrimonial_id) === String(regroupementId)); + }, [enveloppes, regroupementId]); + + function handleSaveLigne(e) { + e?.preventDefault?.(); + setSaveErr(null); + return run(async () => { + try { + const body = { + nom: form.nom, + enveloppe_id: Number(form.enveloppe_id), + institution_id: form.institution_id ? Number(form.institution_id) : null, + investisseur_id: form.investisseur_id ? Number(form.investisseur_id) : null, + // La valeur saisie directement n'est envoyee que s'il n'y a pas de transaction — au + // backend elle reste stockee telle quelle (repli de lignesPatrimoineStmt), mais le + // champ est desactive cote UI des qu'une transaction existe pour eviter toute + // confusion sur laquelle des deux fait foi. + valeur_eur: form.valeur_eur !== '' ? Number(form.valeur_eur) : null, + notes: form.notes || null, + }; + if (isNew) { + const created = await api.post('/lignes-patrimoine', body); + navigate(`/patrimoine/lignes/${created.id}`, { replace: true }); + } else { + await api.put(`/lignes-patrimoine/${id}`, body); + const refreshed = await api.get(`/lignes-patrimoine/${id}`); + setLigne(refreshed); + } + } catch (err) { + setSaveErr(err.message); + } + }); + } + + function handleDeleteLigne() { + return run(async () => { + await api.del(`/lignes-patrimoine/${id}`); + navigate('/'); + }); + } + + function openNewTrans() { + setEditingTransId(null); + setTransForm(emptyTransForm()); + setTransModalOpen(true); + } + function openEditTrans(t) { + setEditingTransId(t.id); + setTransForm({ type: t.type, montant: String(t.montant), date_operation: t.date_operation, libelle: t.libelle || '', notes: t.notes || '' }); + setTransModalOpen(true); + setOpenMenu(null); + } + + function handleSaveTrans(e) { + e?.preventDefault?.(); + return runTrans(async () => { + const body = { + ligne_patrimoine_id: Number(id), + date_operation: transForm.date_operation, + type: transForm.type, + montant: Number(transForm.montant), + libelle: transForm.libelle || null, + notes: transForm.notes || null, + }; + if (editingTransId) { + await api.put(`/transactions-patrimoine/${editingTransId}`, body); + } else { + await api.post('/transactions-patrimoine', body); + } + const refreshed = await api.get('/transactions-patrimoine', { ligne_patrimoine_id: id }); + setTransactions(refreshed); + setTransModalOpen(false); + }); + } + + function handleDeleteTrans() { + return runTrans(async () => { + await api.del(`/transactions-patrimoine/${deleteTransId}`); + const refreshed = await api.get('/transactions-patrimoine', { ligne_patrimoine_id: id }); + setTransactions(refreshed); + setDeleteTransId(null); + }); + } + + const openMenuFor = (e, t) => { + e.stopPropagation(); + const rect = e.currentTarget.getBoundingClientRect(); + setOpenMenu({ trans: t, x: rect.right, y: rect.bottom }); + }; + + if (loading) return

Chargement…

; + if (err) return
{err}
; + + return ( + <> +
+

+ + {isNew ? 'Nouvelle ligne de patrimoine' : (ligne?.nom || '—')} +

+
+ +
+
+
+
Valeur actuelle
+
+ {valeurActuelle != null ? fmtEUR(valeurActuelle) : '—'} +
+
+ {nbTransactions > 0 + ? `Calculee depuis ${nbTransactions} transaction${nbTransactions > 1 ? 's' : ''}` + : 'Saisie directe (aucune transaction enregistrée) — onglet Paramétrage'} +
+
+ {ligne?.enveloppe_nom && ( +
+
Enveloppe
+
{ligne.enveloppe_nom}
+
+ )} + {ligne?.institution_nom && ( +
+
Établissement
+
{ligne.institution_nom}
+
+ )} + {ligne?.investisseur_nom && ( +
+
Détenteur
+
{ligne.investisseur_nom}
+
+ )} +
+
+ + {!isNew && ( +
+ +
+ )} + + {!isNew && ( +
+
+

Transactions

+ +
+ {transactions.length === 0 ? ( +
Aucune transaction enregistrée — la valeur actuelle repose sur la saisie directe (onglet Paramétrage ci-dessous).
+ ) : ( + + + + {transactions.map(t => ( + + + + + + + + ))} + +
DateTypeMontantLibellé
{fmtDate(t.date_operation)}{TYPE_LABELS[t.type] || t.type}{fmtEUR(t.montant)}{t.libelle || '—'} + +
+ )} +
+ )} + +
+

Paramétrage

+ {saveErr &&
{saveErr}
} +
+
+ + + + + +