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() {
+ );
+}
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
+ 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 (
+
+ {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 (
+ <>
+
+ 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 || '—')}
+
+ >
+ )}
+
+ setTransModalOpen(false)}
+ busy={pendingTrans}
+ footer={
+ <>
+
+
+ >
+ }
+ >
+
+
+
+ setDeleteTransId(null)}
+ />
+
+ setDeleteLigneConfirm(false)}
+ />
+ >
+ );
+}
diff --git a/frontend/src/pages/admin/WorkspacesSection.jsx b/frontend/src/pages/admin/WorkspacesSection.jsx
index 5f5b215..7b039cf 100644
--- a/frontend/src/pages/admin/WorkspacesSection.jsx
+++ b/frontend/src/pages/admin/WorkspacesSection.jsx
@@ -45,7 +45,7 @@ function Toggle({ checked, onClick, title }) {
// crowdlending/PE l'app affiche pour ce workspace). Choisi une seule fois à la création, comme
// le slug — jamais modifiable ensuite (changer le type d'un workspace déjà peuplé de données
// changerait silencieusement le modèle de page/API qui s'applique à ces données).
-const WORKSPACE_TYPE_LABELS = { crowdlending: 'Crowdlending', private_equity: 'Private Equity' };
+const WORKSPACE_TYPE_LABELS = { crowdlending: 'Crowdlending', private_equity: 'Private Equity', patrimoine: 'Patrimoine' };
// Valeurs pré-remplies suggérées selon le type choisi (retour d'Olivier, 20/09/26) — appliquées
// uniquement à la création et uniquement sur les champs encore vides (ne jamais écraser ce que
@@ -60,6 +60,10 @@ const WORKSPACE_TYPE_SUGGESTIONS = {
slug: 'private-equity', nom: 'Private Equity', libelleMenu: 'Private Equity',
description: 'Suivi des investissements de type Private Equity',
},
+ patrimoine: {
+ slug: 'patrimoine', nom: 'Patrimoine', libelleMenu: 'Patrimoine',
+ description: 'Vue consolidée du patrimoine par famille d\'actifs',
+ },
};
const emptyForm = { slug: '', nom: '', libelleMenu: '', description: '', type: '', actifGlobal: true, ordre: 0 };
@@ -162,10 +166,12 @@ function WorkspaceFormModal({ open, onClose, onSaved, workspace }) {
+
- Détermine les pages affichées pour cet espace (Crowdlending ou Private Equity) — non
- modifiable après création. Un seul espace de type Crowdlending peut exister.
+ Détermine les pages affichées pour cet espace (Crowdlending, Private Equity ou
+ Patrimoine) — non modifiable après création. Un seul espace de type Crowdlending et
+ un seul de type Patrimoine peuvent exister.