Amélioration de la gestion du patrimoine
This commit is contained in:
1 parent
efa5de79b8
commit
eea69d77a4
22 files changed
+3212
-71
No files matched your search
@@ -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;
|
||||||
+274
-11
@@ -4328,9 +4328,20 @@ db.exec(`
|
|||||||
db.exec('CREATE INDEX IF NOT EXISTS idx_regroupatr_parent ON regroupements_patrimoniaux(parent_id)');
|
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/
|
// 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
|
// réorganiser ensuite, comme pour les catégories). 6 regroupements racine "actif" + 1
|
||||||
// "passif" (Passif, pour les emprunts), puis 1 sous-regroupement "Private Equity" rattaché à
|
// "passif" (Passif, pour les emprunts), puis des sous-regroupements : "Private Equity",
|
||||||
// "Comptes d'investissement" (inséré dans un second temps, une fois l'id parent connu).
|
// "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) {
|
if (db.prepare('SELECT COUNT(*) AS n FROM regroupements_patrimoniaux').get().n === 0) {
|
||||||
const REGROUPEMENTS_RACINE = [
|
const REGROUPEMENTS_RACINE = [
|
||||||
{ nom: 'Immobilier', type: 'actif', ordre: 1 },
|
{ 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: "Comptes d'épargne", type: 'actif', ordre: 3 },
|
||||||
{ nom: 'Crypto', type: 'actif', ordre: 4 },
|
{ nom: 'Crypto', type: 'actif', ordre: 4 },
|
||||||
{ nom: "Comptes d'investissement", type: 'actif', ordre: 5 },
|
{ nom: "Comptes d'investissement", type: 'actif', ordre: 5 },
|
||||||
{ nom: 'Crowdlending', type: 'actif', ordre: 6 },
|
{ nom: 'Autres actifs', type: 'actif', ordre: 6 },
|
||||||
{ nom: 'Autres actifs', type: 'actif', ordre: 7 },
|
{ nom: 'Passif', type: 'passif', ordre: 7 },
|
||||||
{ nom: 'Passif', type: 'passif', ordre: 8 },
|
|
||||||
];
|
];
|
||||||
const insRoot = db.prepare(
|
const insRoot = db.prepare(
|
||||||
'INSERT INTO regroupements_patrimoniaux (nom, type, ordre_affichage) VALUES (?, ?, ?)'
|
'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(() => {
|
db.transaction(() => {
|
||||||
for (const r of REGROUPEMENTS_RACINE) insRoot.run(r.nom, r.type, r.ordre);
|
for (const r of REGROUPEMENTS_RACINE) insRoot.run(r.nom, r.type, r.ordre);
|
||||||
const comptesInv = getByNom.get("Comptes d'investissement");
|
const comptesInv = getByNom.get("Comptes d'investissement");
|
||||||
db.prepare(
|
const insChild = db.prepare(
|
||||||
'INSERT INTO regroupements_patrimoniaux (nom, parent_id, type, ordre_affichage) VALUES (?, ?, ?, ?)'
|
'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,
|
// Colonne de rattachement sur enveloppes_referentiel — ALTER gardé par PRAGMA table_info,
|
||||||
// même patron que eligible_compte/compte_reglement_par_defaut ci-dessus.
|
// 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
|
// 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
|
// 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).
|
// 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 = {
|
const ENVELOPPE_REGROUPEMENT = {
|
||||||
'Comptes courants': [
|
'Comptes courants non rémunérés': [
|
||||||
'Compte courant non rémunéré', 'Compte courant rémunéré',
|
'Compte courant non rémunéré',
|
||||||
'Compte rémunéré nouvelle génération (fintech)',
|
],
|
||||||
|
'Comptes courants rémunérés': [
|
||||||
|
'Compte courant rémunéré', 'Compte rémunéré nouvelle génération (fintech)',
|
||||||
],
|
],
|
||||||
"Comptes d'épargne": [
|
"Comptes d'épargne": [
|
||||||
'Livret A', 'LDDS', 'LEP', 'Livret Jeune', 'CEL', 'PEL',
|
'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',
|
'Assurance-vie monosupport (fonds euros)', 'Assurance-vie multisupport',
|
||||||
'Contrat de capitalisation', 'Assurance-vie luxembourgeoise',
|
'Contrat de capitalisation', 'Assurance-vie luxembourgeoise',
|
||||||
'PER individuel', 'PER collectif / obligatoire (PERCOL, article 83)', 'PEE',
|
'PER individuel', 'PER collectif / obligatoire (PERCOL, article 83)', 'PEE',
|
||||||
|
],
|
||||||
|
'Bourse': [
|
||||||
'PEA', 'PEA-PME', 'Compte-titres ordinaire (CTO)',
|
'PEA', 'PEA-PME', 'Compte-titres ordinaire (CTO)',
|
||||||
],
|
],
|
||||||
'Private Equity': [
|
'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;
|
export default db;
|
||||||
@@ -18,9 +18,12 @@ const router = Router();
|
|||||||
// de cette migration). 'crowdlending' est le singleton historique (un seul exemplaire, imposé
|
// 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) ;
|
// 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
|
// '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.
|
// 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) => {
|
router.get('/', (_req, res) => {
|
||||||
const rows = db.prepare(`
|
const rows = db.prepare(`
|
||||||
@@ -53,12 +56,17 @@ router.post('/', (req, res, next) => {
|
|||||||
const body = WorkspaceCreateSchema.parse(req.body);
|
const body = WorkspaceCreateSchema.parse(req.body);
|
||||||
const exists = db.prepare('SELECT id FROM workspaces WHERE slug = ?').get(body.slug);
|
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é');
|
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
|
// Cf. l'index unique partiel côté DB (idx_workspaces_type_crowdlending_singleton /
|
||||||
// vérification applicative donne un message clair avant même d'atteindre la contrainte SQL.
|
// 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') {
|
if (body.type === 'crowdlending') {
|
||||||
const alreadyCl = db.prepare("SELECT id FROM workspaces WHERE type = 'crowdlending'").get();
|
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 (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(`
|
const r = db.prepare(`
|
||||||
INSERT INTO workspaces (slug, nom, libelle_menu, description, type, actif_global, ordre)
|
INSERT INTO workspaces (slug, nom, libelle_menu, description, type, actif_global, ordre)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Router } from 'express';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import db from '../db/index.js';
|
import db from '../db/index.js';
|
||||||
import { HttpError } from '../middleware/errorHandler.js';
|
import { HttpError } from '../middleware/errorHandler.js';
|
||||||
|
import { recordValorisation } from '../utils/valorisationsPatrimoine.js';
|
||||||
|
|
||||||
const router = Router();
|
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
|
// 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).
|
// ci-dessous via clearOtherDefaults(), pas via une contrainte SQL (investisseur_id nullable).
|
||||||
compte_reglement_par_defaut: z.boolean().optional().default(false),
|
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) {
|
function clearOtherDefaults(userId, investisseurId, excludeId) {
|
||||||
@@ -33,7 +37,7 @@ function clearOtherDefaults(userId, investisseurId, excludeId) {
|
|||||||
router.get('/', (req, res) => {
|
router.get('/', (req, res) => {
|
||||||
const rows = db.prepare(`
|
const rows = db.prepare(`
|
||||||
SELECT c.id, c.nom, c.banque, c.institution_id, c.enveloppe_id, c.exoneration_fiscale,
|
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.nom AS investisseur_nom, inv.prenom AS investisseur_prenom,
|
||||||
inv.type AS investisseur_type, inv.type_fiscal AS investisseur_type_fiscal,
|
inv.type AS investisseur_type, inv.type_fiscal AS investisseur_type_fiscal,
|
||||||
ir.nom AS institution_nom, ir.domiciliation AS institution_domiciliation,
|
ir.nom AS institution_nom, ir.domiciliation AS institution_domiciliation,
|
||||||
@@ -56,8 +60,9 @@ router.post('/', (req, res, next) => {
|
|||||||
const id = db.transaction(() => {
|
const id = db.transaction(() => {
|
||||||
if (defaut) clearOtherDefaults(req.user.id, body.investisseur_id);
|
if (defaut) clearOtherDefaults(req.user.id, body.investisseur_id);
|
||||||
const r = db.prepare(
|
const r = db.prepare(
|
||||||
'INSERT INTO comptes (user_id, nom, banque, institution_id, enveloppe_id, investisseur_id, exoneration_fiscale, compte_reglement_par_defaut) VALUES (?,?,?,?,?,?,?,?)'
|
'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);
|
).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;
|
return r.lastInsertRowid;
|
||||||
})();
|
})();
|
||||||
res.status(201).json({ id, ...body });
|
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 defaut = body.compte_reglement_par_defaut ? 1 : 0;
|
||||||
const changes = db.transaction(() => {
|
const changes = db.transaction(() => {
|
||||||
if (defaut) clearOtherDefaults(req.user.id, body.investisseur_id, Number(req.params.id));
|
if (defaut) clearOtherDefaults(req.user.id, body.investisseur_id, Number(req.params.id));
|
||||||
return db.prepare(
|
const result = 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=?`
|
`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, req.params.id, req.user.id).changes;
|
).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');
|
if (changes === 0) throw new HttpError(404, 'Not found');
|
||||||
res.json({ id: Number(req.params.id), ...body });
|
res.json({ id: Number(req.params.id), ...body });
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -35,8 +35,14 @@ function attachCategories(rows) {
|
|||||||
|
|
||||||
/* GET /api/enveloppes-referentiel-public — liste complète (lecture seule) */
|
/* GET /api/enveloppes-referentiel-public — liste complète (lecture seule) */
|
||||||
router.get('/', (_req, res) => {
|
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(`
|
const rows = db.prepare(`
|
||||||
SELECT id, nom, eligible_compte
|
SELECT id, nom, description, eligible_compte, regroupement_patrimonial_id
|
||||||
FROM enveloppes_referentiel
|
FROM enveloppes_referentiel
|
||||||
ORDER BY nom
|
ORDER BY nom
|
||||||
`).all();
|
`).all();
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -59,6 +59,7 @@ import supportsReferentielRouter from './routes/supportsReferentiel.js';
|
|||||||
import empruntsRouter from './routes/emprunts.js';
|
import empruntsRouter from './routes/emprunts.js';
|
||||||
import refCategoriesEnveloppesRouter from './routes/ref-categories-enveloppes.js';
|
import refCategoriesEnveloppesRouter from './routes/ref-categories-enveloppes.js';
|
||||||
import regroupementsPatrimoniauxRouter from './routes/regroupementsPatrimoniaux.js';
|
import regroupementsPatrimoniauxRouter from './routes/regroupementsPatrimoniaux.js';
|
||||||
|
import regroupementsPatrimoniauxPublicRouter from './routes/regroupementsPatrimoniauxPublic.js';
|
||||||
import refCategoriesRouter from './routes/ref-categories.js';
|
import refCategoriesRouter from './routes/ref-categories.js';
|
||||||
import refSecteursRouter from './routes/ref-secteurs.js';
|
import refSecteursRouter from './routes/ref-secteurs.js';
|
||||||
import categoriesInvRouter from './routes/categories-inv.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 aiRouter from './routes/ai.js';
|
||||||
import workspacesRouter from './routes/workspaces.js';
|
import workspacesRouter from './routes/workspaces.js';
|
||||||
import adminWorkspacesRouter from './routes/adminWorkspaces.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 { requireApiKey } from './middleware/apiKey.js';
|
||||||
import { swaggerSpec, swaggerUi } from './swagger.js';
|
import { swaggerSpec, swaggerUi } from './swagger.js';
|
||||||
import db from './db/index.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/supports-referentiel', requireAuth, requireAdmin, supportsReferentielRouter);
|
||||||
app.use('/api/ref-categories-enveloppes', requireAuth, requireAdmin, refCategoriesEnveloppesRouter);
|
app.use('/api/ref-categories-enveloppes', requireAuth, requireAdmin, refCategoriesEnveloppesRouter);
|
||||||
app.use('/api/regroupements-patrimoniaux', requireAuth, requireAdmin, regroupementsPatrimoniauxRouter);
|
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-categories', requireAuth, requireAdmin, refCategoriesRouter);
|
||||||
app.use('/api/ref-secteurs', requireAuth, requireAdmin, refSecteursRouter);
|
app.use('/api/ref-secteurs', requireAuth, requireAdmin, refSecteursRouter);
|
||||||
app.use('/api/categories-inv', requireAuth, categoriesInvRouter);
|
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/ai', requireAuth, aiRouter);
|
||||||
app.use('/api/workspaces', requireAuth, workspacesRouter);
|
app.use('/api/workspaces', requireAuth, workspacesRouter);
|
||||||
app.use('/api/admin/workspaces', requireAuth, requireAdmin, adminWorkspacesRouter);
|
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);
|
app.use(errorHandler);
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,9 @@ import VerifyEmail from './pages/VerifyEmail.jsx';
|
|||||||
import Layout from './components/Layout.jsx';
|
import Layout from './components/Layout.jsx';
|
||||||
import Dashboard from './pages/Dashboard.jsx';
|
import Dashboard from './pages/Dashboard.jsx';
|
||||||
import DashboardPe from './pages/DashboardPe.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 DepotsRetraits from './pages/DepotsRetraits.jsx';
|
||||||
import DepotsRetraitsSimple from './pages/DepotsRetraitsSimple.jsx';
|
import DepotsRetraitsSimple from './pages/DepotsRetraitsSimple.jsx';
|
||||||
import Investissements from './pages/Investissements.jsx';
|
import Investissements from './pages/Investissements.jsx';
|
||||||
@@ -95,10 +98,23 @@ function RequireWorkspace({ type, children }) {
|
|||||||
// portefeuille), à enrichir ensuite.
|
// portefeuille), à enrichir ensuite.
|
||||||
function WorkspaceHome() {
|
function WorkspaceHome() {
|
||||||
const { active } = useWorkspace() || {};
|
const { active } = useWorkspace() || {};
|
||||||
|
if (active && active.type === 'patrimoine') return <DashboardPatrimoine />;
|
||||||
if (active && active.type !== 'crowdlending') return <DashboardPe />;
|
if (active && active.type !== 'crowdlending') return <DashboardPe />;
|
||||||
return <Dashboard />;
|
return <Dashboard />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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,
|
// 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
|
// 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
|
// plutôt que d'en construire une séparée à chaque nouveau workspace). Bascule
|
||||||
@@ -111,6 +127,7 @@ function WorkspaceHome() {
|
|||||||
// RequireWorkspace ci-dessus.
|
// RequireWorkspace ci-dessus.
|
||||||
function DepotsRetraitsRouter() {
|
function DepotsRetraitsRouter() {
|
||||||
const { active } = useWorkspace() || {};
|
const { active } = useWorkspace() || {};
|
||||||
|
if (isPatrimoineActive(active)) return <Navigate to="/" replace />;
|
||||||
if (active && active.type !== 'crowdlending') return <DepotsRetraitsSimple />;
|
if (active && active.type !== 'crowdlending') return <DepotsRetraitsSimple />;
|
||||||
return <DepotsRetraits />;
|
return <DepotsRetraits />;
|
||||||
}
|
}
|
||||||
@@ -124,6 +141,7 @@ function DepotsRetraitsRouter() {
|
|||||||
// un 3e type de workspace apparaît un jour.
|
// un 3e type de workspace apparaît un jour.
|
||||||
function RemboursementsRouter() {
|
function RemboursementsRouter() {
|
||||||
const { active } = useWorkspace() || {};
|
const { active } = useWorkspace() || {};
|
||||||
|
if (isPatrimoineActive(active)) return <Navigate to="/" replace />;
|
||||||
if (active && active.type !== 'crowdlending') return <RemboursementsSimple />;
|
if (active && active.type !== 'crowdlending') return <RemboursementsSimple />;
|
||||||
return <Remboursements />;
|
return <Remboursements />;
|
||||||
}
|
}
|
||||||
@@ -135,6 +153,7 @@ function RemboursementsRouter() {
|
|||||||
// cf. project_workspaces_transformation.md.
|
// cf. project_workspaces_transformation.md.
|
||||||
function InvestissementsRouter() {
|
function InvestissementsRouter() {
|
||||||
const { active } = useWorkspace() || {};
|
const { active } = useWorkspace() || {};
|
||||||
|
if (isPatrimoineActive(active)) return <Navigate to="/" replace />;
|
||||||
if (active && active.type !== 'crowdlending') return <InvestissementsPe />;
|
if (active && active.type !== 'crowdlending') return <InvestissementsPe />;
|
||||||
return <Investissements />;
|
return <Investissements />;
|
||||||
}
|
}
|
||||||
@@ -144,6 +163,7 @@ function InvestissementsRouter() {
|
|||||||
// frais, documents), à enrichir ensuite (cf. InvestissementPeDetail.jsx).
|
// frais, documents), à enrichir ensuite (cf. InvestissementPeDetail.jsx).
|
||||||
function InvestissementDetailRouter() {
|
function InvestissementDetailRouter() {
|
||||||
const { active } = useWorkspace() || {};
|
const { active } = useWorkspace() || {};
|
||||||
|
if (isPatrimoineActive(active)) return <Navigate to="/" replace />;
|
||||||
if (active && active.type !== 'crowdlending') return <InvestissementPeDetail />;
|
if (active && active.type !== 'crowdlending') return <InvestissementPeDetail />;
|
||||||
return <InvestissementDetail />;
|
return <InvestissementDetail />;
|
||||||
}
|
}
|
||||||
@@ -153,6 +173,7 @@ function InvestissementDetailRouter() {
|
|||||||
// son propre indicateur "au moins un frais déclaré" (FraisOnly / FraisPeOnly ci-dessus).
|
// son propre indicateur "au moins un frais déclaré" (FraisOnly / FraisPeOnly ci-dessus).
|
||||||
function FraisRouter() {
|
function FraisRouter() {
|
||||||
const { active } = useWorkspace() || {};
|
const { active } = useWorkspace() || {};
|
||||||
|
if (isPatrimoineActive(active)) return <Navigate to="/" replace />;
|
||||||
if (active && active.type !== 'crowdlending') return <FraisPeOnly><FraisPe /></FraisPeOnly>;
|
if (active && active.type !== 'crowdlending') return <FraisPeOnly><FraisPe /></FraisPeOnly>;
|
||||||
return <FraisOnly><Frais /></FraisOnly>;
|
return <FraisOnly><Frais /></FraisOnly>;
|
||||||
}
|
}
|
||||||
@@ -163,6 +184,7 @@ function FraisRouter() {
|
|||||||
// cf. project_workspaces_transformation.md).
|
// cf. project_workspaces_transformation.md).
|
||||||
function PlateformesRouter() {
|
function PlateformesRouter() {
|
||||||
const { active } = useWorkspace() || {};
|
const { active } = useWorkspace() || {};
|
||||||
|
if (isPatrimoineActive(active)) return <Navigate to="/" replace />;
|
||||||
if (active && active.type !== 'crowdlending') return <PlateformesPe />;
|
if (active && active.type !== 'crowdlending') return <PlateformesPe />;
|
||||||
return <Plateformes />;
|
return <Plateformes />;
|
||||||
}
|
}
|
||||||
@@ -173,6 +195,7 @@ function PlateformesRouter() {
|
|||||||
// RequireWorkspace (elle l'était jusqu'ici, cf. route "taxreport" plus bas).
|
// RequireWorkspace (elle l'était jusqu'ici, cf. route "taxreport" plus bas).
|
||||||
function TaxReportRouter() {
|
function TaxReportRouter() {
|
||||||
const { active } = useWorkspace() || {};
|
const { active } = useWorkspace() || {};
|
||||||
|
if (isPatrimoineActive(active)) return <Navigate to="/" replace />;
|
||||||
if (active && active.type !== 'crowdlending') return <TaxReportPe />;
|
if (active && active.type !== 'crowdlending') return <TaxReportPe />;
|
||||||
return <TaxReport />;
|
return <TaxReport />;
|
||||||
}
|
}
|
||||||
@@ -228,6 +251,13 @@ export default function App() {
|
|||||||
<Route path="remboursements" element={<RemboursementsRouter />} />
|
<Route path="remboursements" element={<RemboursementsRouter />} />
|
||||||
<Route path="frais" element={<FraisRouter />} />
|
<Route path="frais" element={<FraisRouter />} />
|
||||||
<Route path="simul" element={<RequireWorkspace type="crowdlending"><SimulRemboursements /></RequireWorkspace>} />
|
<Route path="simul" element={<RequireWorkspace type="crowdlending"><SimulRemboursements /></RequireWorkspace>} />
|
||||||
|
{/* 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). */}
|
||||||
|
<Route path="patrimoine/lignes/:id" element={<RequireWorkspace type="patrimoine"><LignePatrimoineDetail /></RequireWorkspace>} />
|
||||||
|
{/* Étape 3 (30/09/26) — aperçu en lecture pour un compte/emprunt existant, cf.
|
||||||
|
claude/plan_visualisation_actif.md. */}
|
||||||
|
<Route path="patrimoine/actif/:type/:id" element={<RequireWorkspace type="patrimoine"><ActifApercu /></RequireWorkspace>} />
|
||||||
<Route path="taxreport" element={<TaxReportRouter />} />
|
<Route path="taxreport" element={<TaxReportRouter />} />
|
||||||
{/* Assistant IA (26/09/26, demande Olivier) : n'est plus réservé au crowdlending —
|
{/* 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
|
visible et accessible aussi depuis l'espace Private Equity (cf. Layout.jsx, nav
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import Logo from './Logo.jsx';
|
|||||||
import UserMenu from './UserMenu.jsx';
|
import UserMenu from './UserMenu.jsx';
|
||||||
import NotificationBell from './NotificationBell.jsx';
|
import NotificationBell from './NotificationBell.jsx';
|
||||||
import AiAssistant from './AiAssistant.jsx';
|
import AiAssistant from './AiAssistant.jsx';
|
||||||
|
import PatrimoineAjoutModal from './PatrimoineAjoutModal.jsx';
|
||||||
|
|
||||||
/* ── Icônes nav ─────────────────────────────────────────────── */
|
/* ── Icônes nav ─────────────────────────────────────────────── */
|
||||||
const ICONS_BASE = '/api/icons-files/';
|
const ICONS_BASE = '/api/icons-files/';
|
||||||
@@ -78,6 +79,11 @@ function ProjectSearch() {
|
|||||||
// d'interroger systematiquement l'endpoint crowdlending. Meme critere que le pattern
|
// d'interroger systematiquement l'endpoint crowdlending. Meme critere que le pattern
|
||||||
// *Router() deja utilise ailleurs (cf. InvestissementDetailRouter, App.jsx).
|
// *Router() deja utilise ailleurs (cf. InvestissementDetailRouter, App.jsx).
|
||||||
const isPe = !!(activeWorkspace && activeWorkspace.type !== 'crowdlending');
|
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 [query, setQuery] = useState('');
|
||||||
const [allInv, setAllInv] = useState([]);
|
const [allInv, setAllInv] = useState([]);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
@@ -87,6 +93,7 @@ function ProjectSearch() {
|
|||||||
|
|
||||||
/* Chargement (ou rechargement) des investissements */
|
/* Chargement (ou rechargement) des investissements */
|
||||||
const loadInv = useCallback(async () => {
|
const loadInv = useCallback(async () => {
|
||||||
|
if (isPatrimoine) { setAllInv([]); return; }
|
||||||
try {
|
try {
|
||||||
const scopeParams = activeView === 'all' ? { scope: 'all' } : {};
|
const scopeParams = activeView === 'all' ? { scope: 'all' } : {};
|
||||||
const endpoint = isPe ? '/investissements-pe' : '/investissements';
|
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.
|
// 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 })));
|
setAllInv(rows.map(r => ({ ...r, nom: isPe ? r.nom_deal : r.nom_projet })));
|
||||||
} catch {}
|
} catch {}
|
||||||
}, [activeView, isPe]);
|
}, [activeView, isPe, isPatrimoine]);
|
||||||
|
|
||||||
useEffect(() => { loadInv(); }, [loadInv, activeId]);
|
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
|
// 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).
|
// avec son propre indicateur "au moins un frais déclaré" (hasFrais / hasFraisPe).
|
||||||
const showFrais = isCrowdlendingActive ? 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 navigate = useNavigate();
|
||||||
const [navIcons, setNavIcons] = useState({});
|
const [navIcons, setNavIcons] = useState({});
|
||||||
const [aiEnabled, setAiEnabled] = useState(false);
|
const [aiEnabled, setAiEnabled] = useState(false);
|
||||||
const [burgerOpen, setBurgerOpen] = useState(false);
|
const [burgerOpen, setBurgerOpen] = useState(false);
|
||||||
|
const [patrimoineAjoutOpen, setPatrimoineAjoutOpen] = useState(false);
|
||||||
const burgerRef = useRef(null);
|
const burgerRef = useRef(null);
|
||||||
|
|
||||||
// Auto-collapse en mode portrait sur tablette (768–1200px)
|
// 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
|
isCrowdlendingActive, pour conserver l'ordre historique du menu crowdlending
|
||||||
(Plateformes, Investissements, Dépôts/Retraits, Remboursements, Frais...) au lieu
|
(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. */}
|
de faire remonter ces liens en tête — cf. capture d'Olivier, 17/09/26. */}
|
||||||
<NavLink to="/plateformes" title="Plateformes">
|
{!isPatrimoine && (
|
||||||
<NavIcon libFilename={navIcons.plateforme} Fallback={IconInvestments} /><span className="nav-label">Plateformes</span>
|
<>
|
||||||
</NavLink>
|
<NavLink to="/plateformes" title="Plateformes">
|
||||||
<NavLink to="/investissements" title="Investissements">
|
<NavIcon libFilename={navIcons.plateforme} Fallback={IconInvestments} /><span className="nav-label">Plateformes</span>
|
||||||
<NavIcon libFilename={navIcons.investissement} Fallback={IconInvestments} /><span className="nav-label">Investissements</span>
|
</NavLink>
|
||||||
</NavLink>
|
<NavLink to="/investissements" title="Investissements">
|
||||||
<NavLink to="/depots-retraits" title="Dépôts / Retraits">
|
<NavIcon libFilename={navIcons.investissement} Fallback={IconInvestments} /><span className="nav-label">Investissements</span>
|
||||||
<NavIcon libFilename={navIcons['depots-retraits']} Fallback={IconDeposits} /><span className="nav-label">Dépôts / Retraits</span>
|
</NavLink>
|
||||||
</NavLink>
|
<NavLink to="/depots-retraits" title="Dépôts / Retraits">
|
||||||
<NavLink to="/remboursements" title="Remboursements">
|
<NavIcon libFilename={navIcons['depots-retraits']} Fallback={IconDeposits} /><span className="nav-label">Dépôts / Retraits</span>
|
||||||
<NavIcon libFilename={navIcons.remboursement} Fallback={IconRepayments} /><span className="nav-label">Remboursements</span>
|
</NavLink>
|
||||||
</NavLink>
|
<NavLink to="/remboursements" title="Remboursements">
|
||||||
{showFrais && (
|
<NavIcon libFilename={navIcons.remboursement} Fallback={IconRepayments} /><span className="nav-label">Remboursements</span>
|
||||||
<NavLink to="/frais" title="Frais">
|
</NavLink>
|
||||||
<NavIcon libFilename={navIcons.frais} Fallback={IconFrais} /><span className="nav-label">Frais</span>
|
{showFrais && (
|
||||||
</NavLink>
|
<NavLink to="/frais" title="Frais">
|
||||||
|
<NavIcon libFilename={navIcons.frais} Fallback={IconFrais} /><span className="nav-label">Frais</span>
|
||||||
|
</NavLink>
|
||||||
|
)}
|
||||||
|
{/* 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. */}
|
||||||
|
<NavLink to="/taxreport" title="Fiscalité">
|
||||||
|
<NavIcon libFilename={navIcons.tax} Fallback={IconFlatTax} /><span className="nav-label">Fiscalité</span>
|
||||||
|
</NavLink>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{/* 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. */}
|
|
||||||
<NavLink to="/taxreport" title="Fiscalité">
|
|
||||||
<NavIcon libFilename={navIcons.tax} Fallback={IconFlatTax} /><span className="nav-label">Fiscalité</span>
|
|
||||||
</NavLink>
|
|
||||||
{/* Assistant IA (26/09/26, demande Olivier : absent de l'espace "Private Equity") —
|
{/* 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
|
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 ;
|
rester visible (toujours en dernière position) quel que soit le workspace actif ;
|
||||||
@@ -418,15 +436,25 @@ export default function Layout() {
|
|||||||
<ProjectSearch />
|
<ProjectSearch />
|
||||||
<div className="topbar-right">
|
<div className="topbar-right">
|
||||||
{/* Boutons visibles sur grand écran */}
|
{/* Boutons visibles sur grand écran */}
|
||||||
<button className="btn-add-invest topbar-action-btn" onClick={() => navigate('/investissements?new=1')}>
|
{!isPatrimoine && (
|
||||||
<IconPlusCircle />Ajout Investissement
|
<>
|
||||||
</button>
|
<button className="btn-add-invest topbar-action-btn" onClick={() => navigate('/investissements?new=1')}>
|
||||||
<button className="btn-add-invest topbar-action-btn" onClick={() => navigate('/remboursements?new=1')}>
|
<IconPlusCircle />Ajout Investissement
|
||||||
<IconPlusCircle />Nouveau remboursement
|
</button>
|
||||||
</button>
|
<button className="btn-add-invest topbar-action-btn" onClick={() => navigate('/remboursements?new=1')}>
|
||||||
<button className="btn-add-invest topbar-action-btn" onClick={() => navigate('/depots-retraits?new=1')}>
|
<IconPlusCircle />Nouveau remboursement
|
||||||
<IconPlusCircle />Nouveau dépôt/retrait
|
</button>
|
||||||
</button>
|
<button className="btn-add-invest topbar-action-btn" onClick={() => navigate('/depots-retraits?new=1')}>
|
||||||
|
<IconPlusCircle />Nouveau dépôt/retrait
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isPatrimoine && (
|
||||||
|
<button className="btn-add-invest topbar-action-btn" onClick={() => setPatrimoineAjoutOpen(true)}>
|
||||||
|
<IconPlusCircle />Compléter mon patrimoine
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Bouton burger — visible sur tablette/mobile */}
|
{/* Bouton burger — visible sur tablette/mobile */}
|
||||||
<div className="topbar-burger-wrap" ref={burgerRef}>
|
<div className="topbar-burger-wrap" ref={burgerRef}>
|
||||||
@@ -444,16 +472,29 @@ export default function Layout() {
|
|||||||
<svg width="15" height="15" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" aria-hidden="true"><circle cx="7.5" cy="7.5" r="5.5"/><line x1="11.5" y1="11.5" x2="16" y2="16"/></svg>
|
<svg width="15" height="15" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" aria-hidden="true"><circle cx="7.5" cy="7.5" r="5.5"/><line x1="11.5" y1="11.5" x2="16" y2="16"/></svg>
|
||||||
Rechercher un projet
|
Rechercher un projet
|
||||||
</button>
|
</button>
|
||||||
<div className="topbar-burger-sep" />
|
{!isPatrimoine && (
|
||||||
<button className="topbar-burger-item" onClick={() => { setBurgerOpen(false); navigate('/investissements?new=1'); }}>
|
<>
|
||||||
<IconPlusCircle />Ajout Investissement
|
<div className="topbar-burger-sep" />
|
||||||
</button>
|
<button className="topbar-burger-item" onClick={() => { setBurgerOpen(false); navigate('/investissements?new=1'); }}>
|
||||||
<button className="topbar-burger-item" onClick={() => { setBurgerOpen(false); navigate('/remboursements?new=1'); }}>
|
<IconPlusCircle />Ajout Investissement
|
||||||
<IconPlusCircle />Nouveau remboursement
|
</button>
|
||||||
</button>
|
<button className="topbar-burger-item" onClick={() => { setBurgerOpen(false); navigate('/remboursements?new=1'); }}>
|
||||||
<button className="topbar-burger-item" onClick={() => { setBurgerOpen(false); navigate('/depots-retraits?new=1'); }}>
|
<IconPlusCircle />Nouveau remboursement
|
||||||
<IconPlusCircle />Nouveau dépôt/retrait
|
</button>
|
||||||
</button>
|
<button className="topbar-burger-item" onClick={() => { setBurgerOpen(false); navigate('/depots-retraits?new=1'); }}>
|
||||||
|
<IconPlusCircle />Nouveau dépôt/retrait
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isPatrimoine && (
|
||||||
|
<>
|
||||||
|
<div className="topbar-burger-sep" />
|
||||||
|
<button className="topbar-burger-item" onClick={() => { setBurgerOpen(false); setPatrimoineAjoutOpen(true); }}>
|
||||||
|
<IconPlusCircle />Compléter mon patrimoine
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<div className="topbar-burger-sep" />
|
<div className="topbar-burger-sep" />
|
||||||
<div className="topbar-burger-toggle">
|
<div className="topbar-burger-toggle">
|
||||||
<span className="topbar-burger-toggle-label">Affichage</span>
|
<span className="topbar-burger-toggle-label">Affichage</span>
|
||||||
@@ -491,6 +532,9 @@ export default function Layout() {
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<AiAssistant />
|
<AiAssistant />
|
||||||
|
{isPatrimoine && (
|
||||||
|
<PatrimoineAjoutModal open={patrimoineAjoutOpen} onClose={() => setPatrimoineAjoutOpen(false)} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { api } from '../api.js';
|
||||||
|
import Modal from './Modal.jsx';
|
||||||
|
import { useWorkspace } from '../context/WorkspaceContext.jsx';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* components/PatrimoineAjoutModal.jsx èè modale "+ Compléter mon patrimoine"
|
||||||
|
* (chantier du 30/09/26, demande Olivier). écran 1 : catégories (regroupements
|
||||||
|
* patrimoniaux racine, icône + courte explication). écran 2 : sous-choix d'actifs
|
||||||
|
* (enveloppes du référentiel rattachées à la catégorie choisie
|
||||||
|
* + ses enfants), chacune menant à la saisie (nouvelle ligne de patrimoine). Cf.
|
||||||
|
* claude/plan_visualisation_actif.md pour le cadrage complet (3 décisions via
|
||||||
|
* AskUserQuestion le 30/09/26).
|
||||||
|
*
|
||||||
|
* Cas particuliers :
|
||||||
|
* - Crowdlending / Private Equity (enfants de "Comptes d'investissement") : tuiles dédiées
|
||||||
|
* qui basculent le workspace actif puis ouvrent /plateformes èè même logique que
|
||||||
|
* le clic sur une ligne du tableau de bord Patrimoine (DashboardPatrimoine.jsx). Grisées
|
||||||
|
* si le workspace correspondant n'est pas actif pour l'utilisateur.
|
||||||
|
* - Passif (Emprunts) : pas d'enveloppe de référentiel (table `emprunts` séparée)
|
||||||
|
* èè une seule tuile "Nouvel emprunt" renvoyant vers Paramétres > Mes emprunts,
|
||||||
|
* même principe que le bouton "Modifier dans Paramétres" de ActifApercu.jsx.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const I = ({ children }) => (
|
||||||
|
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
|
{children}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const IconImmobilier = () => <I><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></I>;
|
||||||
|
const IconComptesCourants = () => <I><rect x="1" y="4" width="22" height="16" rx="2"/><line x1="1" y1="10" x2="23" y2="10"/></I>;
|
||||||
|
const IconEpargne = () => <I><line x1="19" y1="5" x2="5" y2="19"/><circle cx="6.5" cy="6.5" r="2.5"/><circle cx="17.5" cy="17.5" r="2.5"/></I>;
|
||||||
|
const IconCrypto = () => <I><circle cx="12" cy="12" r="9"/><path d="M9 8.5c0-1 .9-1.5 2-1.5h1c1.4 0 2.5.9 2.5 2s-1.1 2-2.5 2h-1c-1.4 0-2.5.9-2.5 2s1.1 2 2.5 2h1c1.1 0 2-.5 2-1.5"/><line x1="12" y1="5.5" x2="12" y2="18.5"/></I>;
|
||||||
|
const IconInvestissement = () => <I><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"/><polyline points="17 6 23 6 23 12"/></I>;
|
||||||
|
const IconAutresActifs = () => <I><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></I>;
|
||||||
|
const IconPassif = () => <I><circle cx="12" cy="12" r="10"/><polyline points="8 12 12 16 16 12"/><line x1="12" y1="8" x2="12" y2="16"/></I>;
|
||||||
|
const IconGenerique = () => <I><path d="M20.59 13.41L13.42 20.58a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/></I>;
|
||||||
|
|
||||||
|
const CATEGORY_META = {
|
||||||
|
'Immobilier': { icon: IconImmobilier, description: "Résidences, locatif, SCPI, OPCI, SCI…" },
|
||||||
|
'Comptes courants': { icon: IconComptesCourants, description: "Comptes courants rémunérés ou non." },
|
||||||
|
"Comptes d'épargne": { icon: IconEpargne, description: "Livrets réglementés ou bancaires, CEL, PEL…" },
|
||||||
|
'Crypto': { icon: IconCrypto, description: "Comptes exchange, wallets auto-conservés ou chez un tiers." },
|
||||||
|
"Comptes d'investissement": { icon: IconInvestissement, description: "Assurance-vie, PER, PEE, Bourse, Crowdlending, Private Equity…" },
|
||||||
|
'Autres actifs': { icon: IconAutresActifs, description: "Métaux précieux, objets de collection, forêt, vigne…" },
|
||||||
|
'Passif': { icon: IconPassif, description: "Emprunts en cours." },
|
||||||
|
};
|
||||||
|
const DEFAULT_CATEGORY_META = { icon: IconGenerique, description: '' };
|
||||||
|
|
||||||
|
function CategoryIcon({ nom }) {
|
||||||
|
const Comp = (CATEGORY_META[nom] || DEFAULT_CATEGORY_META).icon;
|
||||||
|
return <Comp />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PatrimoineAjoutModal({ open, onClose }) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { activeWorkspaces, setActiveWorkspace } = useWorkspace() || {};
|
||||||
|
|
||||||
|
const [step, setStep] = useState('categorie'); // 'categorie' | 'actif'
|
||||||
|
const [selected, setSelected] = useState(null);
|
||||||
|
const [regroupements, setRegroupements] = useState(null);
|
||||||
|
const [enveloppes, setEnveloppes] = useState(null);
|
||||||
|
const [err, setErr] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setStep('categorie');
|
||||||
|
setSelected(null);
|
||||||
|
setErr(null);
|
||||||
|
Promise.all([
|
||||||
|
api.get('/regroupements-patrimoniaux-public'),
|
||||||
|
api.get('/enveloppes-referentiel-public'),
|
||||||
|
])
|
||||||
|
.then(([r, e]) => { setRegroupements(r); setEnveloppes(e); })
|
||||||
|
.catch(ex => setErr(ex.message));
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const racines = useMemo(
|
||||||
|
() => (regroupements || []).filter(r => !r.parent_id).sort((a, b) => a.ordre_affichage - b.ordre_affichage),
|
||||||
|
[regroupements]
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleClose() { onClose(); }
|
||||||
|
|
||||||
|
// Important : on passe le regroupement_patrimonial_id propre de l'enveloppe (qui peut etre
|
||||||
|
// un enfant du regroupement affiche, ex. "Bourse" sous "Comptes d'investissement"), pas
|
||||||
|
// selected.id (le noeud racine cliqué a l'ecran 1) -- sinon le selecteur Enveloppe de
|
||||||
|
// LignePatrimoineDetail.jsx, filtre exactement sur regroupement_id, exclurait l'enveloppe
|
||||||
|
// pre-selectionnee (cf. son filtre enveloppesFiltrees).
|
||||||
|
function goToLigne(enveloppeId, regroupementId) {
|
||||||
|
navigate(`/patrimoine/lignes/nouveau?enveloppe_id=${enveloppeId}®roupement_id=${regroupementId}`);
|
||||||
|
handleClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToWorkspace(wsType) {
|
||||||
|
const target = (activeWorkspaces || []).find(w => w.type === wsType);
|
||||||
|
if (!target) return;
|
||||||
|
setActiveWorkspace(target.id);
|
||||||
|
navigate('/plateformes');
|
||||||
|
handleClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToEmprunt() {
|
||||||
|
navigate('/settings?section=emprunts');
|
||||||
|
handleClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
// écran 2 : construit les tuiles "sous-choix d'actifs" pour la catégorie choisie.
|
||||||
|
const actifTiles = useMemo(() => {
|
||||||
|
if (!selected || !regroupements || !enveloppes) return [];
|
||||||
|
if (selected.nom === 'Passif') {
|
||||||
|
return [{
|
||||||
|
key: 'emprunt',
|
||||||
|
nom: 'Nouvel emprunt',
|
||||||
|
description: "Montant emprunté, capital restant dû, taux, échéancier.",
|
||||||
|
onClick: goToEmprunt,
|
||||||
|
disabled: false,
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
const enfants = regroupements.filter(r => r.parent_id === selected.id);
|
||||||
|
const enfantsPlateforme = enfants.filter(r => r.nom === 'Crowdlending' || r.nom === 'Private Equity');
|
||||||
|
const enfantsEnveloppe = enfants.filter(r => r.nom !== 'Crowdlending' && r.nom !== 'Private Equity');
|
||||||
|
const idsEnveloppes = [selected.id, ...enfantsEnveloppe.map(r => r.id)];
|
||||||
|
|
||||||
|
const tuilesEnveloppes = enveloppes
|
||||||
|
.filter(e => idsEnveloppes.includes(e.regroupement_patrimonial_id))
|
||||||
|
.sort((a, b) => a.nom.localeCompare(b.nom, 'fr'))
|
||||||
|
.map(e => ({
|
||||||
|
key: `env-${e.id}`,
|
||||||
|
nom: e.nom,
|
||||||
|
description: e.description || '',
|
||||||
|
onClick: () => goToLigne(e.id, e.regroupement_patrimonial_id),
|
||||||
|
disabled: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const tuilesPlateforme = enfantsPlateforme.map(r => {
|
||||||
|
const wsType = r.nom === 'Crowdlending' ? 'crowdlending' : 'private_equity';
|
||||||
|
const target = (activeWorkspaces || []).find(w => w.type === wsType);
|
||||||
|
return {
|
||||||
|
key: `ws-${r.id}`,
|
||||||
|
nom: r.nom,
|
||||||
|
description: target
|
||||||
|
? `Géré depuis l'espace ${r.nom}.`
|
||||||
|
: `Espace ${r.nom} non activé pour votre compte.`,
|
||||||
|
onClick: () => goToWorkspace(wsType),
|
||||||
|
disabled: !target,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return [...tuilesEnveloppes, ...tuilesPlateforme];
|
||||||
|
}, [selected, regroupements, enveloppes, activeWorkspaces]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
onClose={handleClose}
|
||||||
|
width={780}
|
||||||
|
title={step === 'categorie' ? 'Compléter mon patrimoine' : (
|
||||||
|
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<button className="ghost" onClick={() => setStep('categorie')} title="Retour aux catégories" style={{ padding: '2px 8px' }}>←</button>
|
||||||
|
{selected?.nom}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{err && <div className="error">{err}</div>}
|
||||||
|
{!err && !regroupements && <div className="text-muted">Chargement…</div>}
|
||||||
|
|
||||||
|
{!err && regroupements && step === 'categorie' && (
|
||||||
|
<div className="patrimoine-ajout-grid">
|
||||||
|
{racines.map(r => (
|
||||||
|
<button
|
||||||
|
key={r.id}
|
||||||
|
className="patrimoine-ajout-tile"
|
||||||
|
onClick={() => { setSelected(r); setStep('actif'); }}
|
||||||
|
>
|
||||||
|
<span className="patrimoine-ajout-tile-icon"><CategoryIcon nom={r.nom} /></span>
|
||||||
|
<span className="patrimoine-ajout-tile-title">{r.nom}</span>
|
||||||
|
<span className="patrimoine-ajout-tile-desc">{(CATEGORY_META[r.nom] || DEFAULT_CATEGORY_META).description}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!err && regroupements && step === 'actif' && (
|
||||||
|
<div className="patrimoine-ajout-grid">
|
||||||
|
{actifTiles.length === 0 && (
|
||||||
|
<div className="text-muted">Aucun type d'actif disponible dans cette catégorie pour le moment.</div>
|
||||||
|
)}
|
||||||
|
{actifTiles.map(t => (
|
||||||
|
<button
|
||||||
|
key={t.key}
|
||||||
|
className="patrimoine-ajout-tile"
|
||||||
|
onClick={t.onClick}
|
||||||
|
disabled={t.disabled}
|
||||||
|
title={t.disabled ? t.description : undefined}
|
||||||
|
>
|
||||||
|
<span className="patrimoine-ajout-tile-icon"><CategoryIcon nom={selected.nom} /></span>
|
||||||
|
<span className="patrimoine-ajout-tile-title">{t.nom}</span>
|
||||||
|
<span className="patrimoine-ajout-tile-desc">{t.description}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
import { useMemo, useState, useRef } from 'react';
|
||||||
|
|
||||||
|
/* ── Constantes ─────────────────────────────────────────────── */
|
||||||
|
const BLUE = 'var(--primary)';
|
||||||
|
const BG = 'var(--surface)';
|
||||||
|
const GRID = 'var(--border)';
|
||||||
|
const LABEL = 'var(--text-muted)';
|
||||||
|
|
||||||
|
const RANGES = ['1J', '7J', '1M', '3M', 'YTD', '1A', 'TOUT'];
|
||||||
|
const MOIS_COURT = ['jan.','fév.','mar.','avr.','mai','juin','juil.','août','sep.','oct.','nov.','déc.'];
|
||||||
|
|
||||||
|
const W = 900, H = 260;
|
||||||
|
const PAD = { top: 16, right: 16, bottom: 32, left: 70 };
|
||||||
|
const plotW = W - PAD.left - PAD.right;
|
||||||
|
const plotH = H - PAD.top - PAD.bottom;
|
||||||
|
|
||||||
|
/* ── Helpers (identiques à ValorisationPeChart.jsx) ────────────── */
|
||||||
|
function fmtK(v) {
|
||||||
|
if (v === 0) return '0 €';
|
||||||
|
const abs = Math.abs(v);
|
||||||
|
if (abs >= 1000) return (v / 1000).toLocaleString('fr-FR', { maximumFractionDigits: 0 }) + ' k €';
|
||||||
|
return v.toLocaleString('fr-FR', { maximumFractionDigits: 0 }) + ' €';
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtAxisDate(dateStr, range) {
|
||||||
|
const d = new Date(dateStr + 'T00:00:00');
|
||||||
|
const day = String(d.getDate()).padStart(2, '0');
|
||||||
|
const mon = MOIS_COURT[d.getMonth()];
|
||||||
|
const yr = d.getFullYear();
|
||||||
|
if (range === '1J' || range === '7J') return `${day}/${String(d.getMonth()+1).padStart(2,'0')}`;
|
||||||
|
if (range === '1M' || range === '3M') return `${day} ${mon}`;
|
||||||
|
return `${day}/${String(d.getMonth()+1).padStart(2,'0')}/${String(yr).slice(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtValueDisplay(v) {
|
||||||
|
return v.toLocaleString('fr-FR', { minimumFractionDigits: 0, maximumFractionDigits: 0 }) + ' €';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Graphique "Historique de valeur" d'un actif de patrimoine (compte ou ligne_patrimoine) —
|
||||||
|
* chantier "Historique de valeur" (30/09/26, demande Olivier). Reprend la structure SVG
|
||||||
|
* dessinée à la main de ValorisationPeChart.jsx (mêmes constantes W/H/PAD, mêmes plages
|
||||||
|
* RANGES, même tooltip au survol), mais avec une sémantique plus simple : un seul actif, une
|
||||||
|
* seule série de points {date, valeur} — pas d'agrégation multi-deals. `rows` est le résultat
|
||||||
|
* brut de GET /api/valorisations-patrimoine?compte_id=...|ligne_patrimoine_id=... (déjà trié
|
||||||
|
* ASC par la route sur date_valorisation, id).
|
||||||
|
*
|
||||||
|
* Contrairement à ValorisationPeChart (qui prend 0 comme valeur de repli "avant tout deal"),
|
||||||
|
* il n'y a pas de "0 avant le premier point" naturel ici : un actif non encore suivi n'avait pas
|
||||||
|
* une valeur de 0, il n'était simplement pas encore renseigné. Le repli utilisé est donc la
|
||||||
|
* valeur du point le plus proche disponible (avant, sinon le premier après).
|
||||||
|
*/
|
||||||
|
export default function ValorisationPatrimoineChart({ rows, title = 'Historique de valeur' }) {
|
||||||
|
const [range, setRange] = useState('TOUT');
|
||||||
|
const [hover, setHover] = useState(null);
|
||||||
|
const svgRef = useRef(null);
|
||||||
|
|
||||||
|
const todayStr = new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
/* ── 1. Normalisation de la série ── */
|
||||||
|
const allPoints = useMemo(() => {
|
||||||
|
if (!rows?.length) return [];
|
||||||
|
return rows
|
||||||
|
.filter(r => r.date_valorisation && r.valeur != null)
|
||||||
|
.map(r => ({ date: r.date_valorisation.slice(0, 10), value: r.valeur }));
|
||||||
|
}, [rows]);
|
||||||
|
|
||||||
|
const currentValue = allPoints.length ? allPoints[allPoints.length - 1].value : null;
|
||||||
|
const currentDate = allPoints.length ? allPoints[allPoints.length - 1].date : null;
|
||||||
|
|
||||||
|
/* ── 2. Filtrage par plage ── */
|
||||||
|
const points = useMemo(() => {
|
||||||
|
if (allPoints.length < 2) return [];
|
||||||
|
if (range === 'TOUT') {
|
||||||
|
const pts = [...allPoints];
|
||||||
|
if (pts[pts.length - 1].date < todayStr) pts.push({ date: todayStr, value: pts[pts.length - 1].value });
|
||||||
|
return pts;
|
||||||
|
}
|
||||||
|
const now = new Date(todayStr + 'T00:00:00');
|
||||||
|
let fromDate = new Date(now);
|
||||||
|
switch (range) {
|
||||||
|
case '1J': fromDate.setDate(now.getDate() - 1); break;
|
||||||
|
case '7J': fromDate.setDate(now.getDate() - 7); break;
|
||||||
|
case '1M': fromDate.setMonth(now.getMonth() - 1); break;
|
||||||
|
case '3M': fromDate.setMonth(now.getMonth() - 3); break;
|
||||||
|
case 'YTD': fromDate = new Date(now.getFullYear(), 0, 1); break;
|
||||||
|
case '1A': fromDate.setFullYear(now.getFullYear() - 1); break;
|
||||||
|
}
|
||||||
|
const fromStr = fromDate.toISOString().slice(0, 10);
|
||||||
|
const before = allPoints.filter(p => p.date < fromStr);
|
||||||
|
const after = allPoints.filter(p => p.date >= fromStr);
|
||||||
|
const startV = before.length ? before[before.length - 1].value : (after.length ? after[0].value : allPoints[0].value);
|
||||||
|
const pts = after.length ? [{ date: fromStr, value: startV }, ...after] : [{ date: fromStr, value: startV }];
|
||||||
|
if (pts[pts.length - 1].date < todayStr) pts.push({ date: todayStr, value: pts[pts.length - 1].value });
|
||||||
|
return pts;
|
||||||
|
}, [allPoints, range, todayStr]);
|
||||||
|
|
||||||
|
/* ── 3. Échelles ── */
|
||||||
|
const { xScale, yScale, yTicks, xTicks, minDate, maxDate, yZero } = useMemo(() => {
|
||||||
|
if (points.length < 2) return {};
|
||||||
|
const vals = points.map(p => p.value);
|
||||||
|
const dataMin = Math.min(...vals);
|
||||||
|
const dataMax = Math.max(...vals);
|
||||||
|
const lo = Math.min(0, dataMin);
|
||||||
|
const hi = Math.max(0, dataMax);
|
||||||
|
const pad = (hi - lo) * 0.1 || 10;
|
||||||
|
const scaleLo = lo - (lo < 0 ? pad : 0);
|
||||||
|
const scaleHi = hi + pad;
|
||||||
|
const valRange = scaleHi - scaleLo || 1;
|
||||||
|
|
||||||
|
const ts = points.map(p => new Date(p.date + 'T00:00:00').getTime());
|
||||||
|
const minDt = ts[0];
|
||||||
|
const maxDt = ts[ts.length - 1];
|
||||||
|
const dtRange = maxDt - minDt || 1;
|
||||||
|
|
||||||
|
const xScale = d => PAD.left + ((new Date(d + 'T00:00:00').getTime() - minDt) / dtRange) * plotW;
|
||||||
|
const yScale = v => PAD.top + plotH - ((v - scaleLo) / valRange) * plotH;
|
||||||
|
|
||||||
|
const step = (scaleHi - scaleLo) / 4;
|
||||||
|
const yTicks = Array.from({ length: 5 }, (_, i) => ({ v: scaleLo + step * i, y: yScale(scaleLo + step * i) }));
|
||||||
|
|
||||||
|
const nX = Math.min(8, points.length);
|
||||||
|
const xTicks = Array.from({ length: nX }, (_, i) => {
|
||||||
|
const idx = Math.round((i / (nX - 1)) * (points.length - 1));
|
||||||
|
return points[idx];
|
||||||
|
});
|
||||||
|
|
||||||
|
return { xScale, yScale, yTicks, xTicks, minDate: minDt, maxDate: maxDt, yZero: yScale(0) };
|
||||||
|
}, [points]);
|
||||||
|
|
||||||
|
/* ── 4. Chemins SVG ── */
|
||||||
|
const { linePath, areaPath } = useMemo(() => {
|
||||||
|
if (!xScale || points.length < 2) return { linePath: '', areaPath: '' };
|
||||||
|
let line = `M ${xScale(points[0].date).toFixed(1)},${yScale(points[0].value).toFixed(1)}`;
|
||||||
|
for (let i = 1; i < points.length; i++) {
|
||||||
|
line += ` H ${xScale(points[i].date).toFixed(1)} V ${yScale(points[i].value).toFixed(1)}`;
|
||||||
|
}
|
||||||
|
const zeroY = yZero?.toFixed(1) ?? (PAD.top + plotH).toFixed(1);
|
||||||
|
const area = `${line} V ${zeroY} H ${PAD.left} Z`;
|
||||||
|
return { linePath: line, areaPath: area };
|
||||||
|
}, [points, xScale, yScale, yZero]);
|
||||||
|
|
||||||
|
/* ── 5. Hover ── */
|
||||||
|
const handleMouseMove = (e) => {
|
||||||
|
if (!svgRef.current || !xScale || points.length < 2) return;
|
||||||
|
const rect = svgRef.current.getBoundingClientRect();
|
||||||
|
const svgX = ((e.clientX - rect.left) / rect.width) * W;
|
||||||
|
const t = minDate + ((svgX - PAD.left) / plotW) * (maxDate - minDate);
|
||||||
|
let nearest = points[0], minDiff = Infinity;
|
||||||
|
for (const p of points) {
|
||||||
|
const diff = Math.abs(new Date(p.date + 'T00:00:00').getTime() - t);
|
||||||
|
if (diff < minDiff) { minDiff = diff; nearest = p; }
|
||||||
|
}
|
||||||
|
setHover({ x: xScale(nearest.date), y: yScale(nearest.value), value: nearest.value, date: nearest.date });
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ── Tooltip ── */
|
||||||
|
const tooltipStyle = useMemo(() => {
|
||||||
|
if (!hover) return null;
|
||||||
|
const xPct = (hover.x / W) * 100;
|
||||||
|
const yPct = (hover.y / H) * 100;
|
||||||
|
const anchorRight = xPct > 65;
|
||||||
|
return {
|
||||||
|
position: 'absolute',
|
||||||
|
top: `calc(${yPct}% - 64px)`,
|
||||||
|
left: anchorRight ? 'auto' : `calc(${xPct}% + 12px)`,
|
||||||
|
right: anchorRight ? `calc(${100 - xPct}% + 12px)` : 'auto',
|
||||||
|
transform: 'none',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
};
|
||||||
|
}, [hover]);
|
||||||
|
|
||||||
|
if (!allPoints.length) return null;
|
||||||
|
|
||||||
|
// Moins de 2 points : rien à tracer (une droite a besoin de 2 points), on affiche juste la
|
||||||
|
// valeur connue et une note plutôt que rien du tout — meilleur que de masquer complètement
|
||||||
|
// le bloc, l'utilisateur comprend pourquoi il n'y a pas encore de courbe.
|
||||||
|
if (allPoints.length < 2) {
|
||||||
|
return (
|
||||||
|
<div className="solde-chart-wrap">
|
||||||
|
<div className="solde-chart-header">
|
||||||
|
<div className="solde-chart-info">
|
||||||
|
<div className="solde-chart-date">
|
||||||
|
{title} · {new Date(currentDate + 'T00:00:00').toLocaleDateString('fr-FR', { day: '2-digit', month: 'long', year: 'numeric' })}
|
||||||
|
</div>
|
||||||
|
<div className="solde-chart-value">{fmtValueDisplay(currentValue)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p style={{ color: 'var(--text-muted)', fontSize: '13px', margin: '8px 0 0' }}>
|
||||||
|
Un seul point d'historique pour l'instant — la courbe apparaîtra dès la prochaine mise à jour de la valeur.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayDate = hover
|
||||||
|
? new Date(hover.date + 'T00:00:00').toLocaleDateString('fr-FR', { day: '2-digit', month: 'long', year: 'numeric' })
|
||||||
|
: new Date(currentDate + 'T00:00:00').toLocaleDateString('fr-FR', { day: '2-digit', month: 'long', year: 'numeric' });
|
||||||
|
const displayValue = fmtValueDisplay(hover ? hover.value : currentValue);
|
||||||
|
const tooltipDate = hover
|
||||||
|
? new Date(hover.date + 'T00:00:00').toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="solde-chart-wrap" style={{ position: 'relative' }}>
|
||||||
|
|
||||||
|
{/* ── En-tête ── */}
|
||||||
|
<div className="solde-chart-header">
|
||||||
|
<div className="solde-chart-info">
|
||||||
|
<div className="solde-chart-date">{title} · {displayDate}</div>
|
||||||
|
<div className="solde-chart-value">{displayValue}</div>
|
||||||
|
</div>
|
||||||
|
<div className="solde-chart-controls">
|
||||||
|
<div className="solde-chart-ranges">
|
||||||
|
{RANGES.map(r => (
|
||||||
|
<button key={r}
|
||||||
|
className={`solde-range-btn${range === r ? ' active' : ''}`}
|
||||||
|
onClick={() => { setRange(r); setHover(null); }}>
|
||||||
|
{r}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── SVG ── */}
|
||||||
|
{xScale && (
|
||||||
|
<svg
|
||||||
|
ref={svgRef}
|
||||||
|
viewBox={`0 0 ${W} ${H}`}
|
||||||
|
style={{ width: '100%', height: 'auto', display: 'block', cursor: 'crosshair' }}
|
||||||
|
onMouseMove={handleMouseMove}
|
||||||
|
onMouseLeave={() => setHover(null)}
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="patval-fill" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor={BLUE} stopOpacity="0.22" />
|
||||||
|
<stop offset="70%" stopColor={BLUE} stopOpacity="0.06" />
|
||||||
|
<stop offset="100%" stopColor={BLUE} stopOpacity="0" />
|
||||||
|
</linearGradient>
|
||||||
|
<filter id="patval-glow">
|
||||||
|
<feGaussianBlur stdDeviation="1.5" result="blur"/>
|
||||||
|
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
{/* Grille horizontale */}
|
||||||
|
{yTicks.map(({ v, y }) => (
|
||||||
|
<g key={v}>
|
||||||
|
<line x1={PAD.left} y1={y} x2={W - PAD.right} y2={y}
|
||||||
|
stroke={GRID} strokeWidth="1" strokeDasharray="3 5" />
|
||||||
|
<text x={PAD.left - 10} y={y + 4} textAnchor="end"
|
||||||
|
fill={LABEL} fontSize="11" fontFamily="system-ui,sans-serif">
|
||||||
|
{fmtK(v)}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Ligne zéro */}
|
||||||
|
{yZero && yZero > PAD.top && yZero < PAD.top + plotH && (
|
||||||
|
<line x1={PAD.left} y1={yZero} x2={W - PAD.right} y2={yZero}
|
||||||
|
stroke={GRID} strokeWidth="1" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Aire dégradée + courbe */}
|
||||||
|
<path d={areaPath} fill="url(#patval-fill)" />
|
||||||
|
<path d={linePath} fill="none" stroke={BLUE} strokeWidth="1.5"
|
||||||
|
filter="url(#patval-glow)" strokeLinejoin="round" />
|
||||||
|
|
||||||
|
{/* Labels axe X */}
|
||||||
|
{xTicks.map((p, i) => {
|
||||||
|
const anchor = i === 0 ? 'start' : i === xTicks.length - 1 ? 'end' : 'middle';
|
||||||
|
return (
|
||||||
|
<text key={i} x={xScale(p.date)} y={H - 8}
|
||||||
|
textAnchor={anchor} fill={LABEL} fontSize="10"
|
||||||
|
fontFamily="system-ui,sans-serif">
|
||||||
|
{fmtAxisDate(p.date, range)}
|
||||||
|
</text>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Hover : ligne verticale + point */}
|
||||||
|
{hover && (
|
||||||
|
<g>
|
||||||
|
<line x1={hover.x} y1={PAD.top} x2={hover.x} y2={PAD.top + plotH}
|
||||||
|
stroke={GRID} strokeWidth="1" strokeDasharray="3 4" />
|
||||||
|
<circle cx={hover.x} cy={hover.y} r="4.5"
|
||||||
|
fill={BLUE} stroke={BG} strokeWidth="2" />
|
||||||
|
</g>
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Tooltip flottant ── */}
|
||||||
|
{hover && tooltipStyle && (
|
||||||
|
<div style={tooltipStyle}>
|
||||||
|
<div className="sg-tooltip">
|
||||||
|
<span className="sg-tooltip-date">{tooltipDate}</span>
|
||||||
|
<span className="sg-tooltip-value">{fmtValueDisplay(hover.value)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 <div className="error">Type d'actif inconnu.</div>;
|
||||||
|
if (loading) return <div className="topbar"><h2>Chargement…</h2></div>;
|
||||||
|
if (err) return <div className="error">{err}</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="topbar">
|
||||||
|
<h2>
|
||||||
|
<button className="ghost" onClick={() => navigate('/')} title="Retour au tableau de bord Patrimoine" style={{ marginRight: 8 }}>← Patrimoine</button>
|
||||||
|
{row.nom}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card" style={{ marginBottom: 20 }}>
|
||||||
|
<div className="kpi-grid">
|
||||||
|
{type === 'compte' && (
|
||||||
|
<div className="kpi">
|
||||||
|
<div className="label">Solde</div>
|
||||||
|
<div style={{ fontSize: '1.35rem', fontWeight: 700, marginTop: 4 }}>
|
||||||
|
{row.solde_eur == null
|
||||||
|
? <span className="text-muted" style={{ fontWeight: 400, fontStyle: 'italic', fontSize: '0.85rem' }}>Non renseigné</span>
|
||||||
|
: fmtEUR(row.solde_eur)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{type === 'emprunt' && (
|
||||||
|
<>
|
||||||
|
<div className="kpi">
|
||||||
|
<div className="label">Capital restant dû</div>
|
||||||
|
<div style={{ fontSize: '1.35rem', fontWeight: 700, marginTop: 4 }}>{fmtEUR(row.capital_restant_du)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="kpi">
|
||||||
|
<div className="label">Montant emprunté</div>
|
||||||
|
<div style={{ fontSize: '1.1rem', fontWeight: 600, marginTop: 4 }}>{fmtEUR(row.montant_emprunte)}</div>
|
||||||
|
</div>
|
||||||
|
{row.taux_nominal != null && (
|
||||||
|
<div className="kpi">
|
||||||
|
<div className="label">Taux nominal</div>
|
||||||
|
<div style={{ fontSize: '1.1rem', fontWeight: 600, marginTop: 4 }}>{row.taux_nominal} %</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{row.mensualite != null && (
|
||||||
|
<div className="kpi">
|
||||||
|
<div className="label">Mensualité</div>
|
||||||
|
<div style={{ fontSize: '1.1rem', fontWeight: 600, marginTop: 4 }}>{fmtEUR(row.mensualite)}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{row.date_debut && (
|
||||||
|
<div className="kpi">
|
||||||
|
<div className="label">Date de début</div>
|
||||||
|
<div style={{ fontSize: '1.1rem', fontWeight: 600, marginTop: 4 }}>{fmtDate(row.date_debut)}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{row.duree_mois != null && (
|
||||||
|
<div className="kpi">
|
||||||
|
<div className="label">Durée</div>
|
||||||
|
<div style={{ fontSize: '1.1rem', fontWeight: 600, marginTop: 4 }}>{row.duree_mois} mois</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{row.enveloppe_nom && (
|
||||||
|
<div className="kpi">
|
||||||
|
<div className="label">Enveloppe</div>
|
||||||
|
<div style={{ fontSize: '1.1rem', fontWeight: 600, marginTop: 4 }}>{row.enveloppe_nom}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(row.institution_nom || row.banque) && (
|
||||||
|
<div className="kpi">
|
||||||
|
<div className="label">Établissement</div>
|
||||||
|
<div style={{ fontSize: '1.1rem', fontWeight: 600, marginTop: 4 }}>{row.institution_nom || row.banque}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{row.investisseur_nom && (
|
||||||
|
<div className="kpi">
|
||||||
|
<div className="label">Détenteur</div>
|
||||||
|
<div style={{ fontSize: '1.1rem', fontWeight: 600, marginTop: 4 }}>{row.investisseur_nom}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{type === 'compte' && (
|
||||||
|
<div className="card" style={{ marginBottom: 20 }}>
|
||||||
|
<ValorisationPatrimoineChart rows={valorisations} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<h3 style={{ marginTop: 0 }}>Paramétrage</h3>
|
||||||
|
<p className="text-muted" style={{ marginTop: 0 }}>
|
||||||
|
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é.
|
||||||
|
</p>
|
||||||
|
<button className="primary" onClick={() => navigate(`/settings?section=${meta.settingsSection}`)}>
|
||||||
|
Modifier dans Paramètres
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="kpi">
|
||||||
|
<div className="label">{title}</div>
|
||||||
|
<div style={{ fontSize: '1.35rem', fontWeight: 700, marginTop: 4, color: color || 'var(--text)' }}>{value}</div>
|
||||||
|
{note && <div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{note}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconChevron({ open }) {
|
||||||
|
return (
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.6"
|
||||||
|
strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"
|
||||||
|
style={{ transform: open ? 'rotate(90deg)' : 'none', transition: 'transform .12s', flexShrink: 0, color: 'var(--text-muted)' }}>
|
||||||
|
<polyline points="9 6 15 12 9 18" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<tr
|
||||||
|
style={clickable ? { cursor: 'pointer' } : undefined}
|
||||||
|
onClick={clickable ? handleClick : undefined}
|
||||||
|
>
|
||||||
|
<td style={{ paddingLeft: 14 + depth * 22 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 6 }}>
|
||||||
|
{icon && (
|
||||||
|
<img src={icon} alt=""
|
||||||
|
style={{ width: 18, height: 18, objectFit: 'contain', borderRadius: 3, flexShrink: 0, marginTop: 1 }} />
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 500, fontSize: '0.94em' }}>{ligne.label}</div>
|
||||||
|
<div style={{ fontSize: '0.8em', color: 'var(--text-muted)' }}>
|
||||||
|
{ligne.sous_label || '—'}
|
||||||
|
{ligne.nb_lignes > 1 && ` · ${ligne.nb_lignes} investissements`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="text-muted">{fmtPct(ligne.valeur_eur, grandTotal)}</td>
|
||||||
|
<td className="text-muted">{ligne.detenteur || '—'}</td>
|
||||||
|
<td style={{ textAlign: 'right', fontWeight: 600 }}>
|
||||||
|
{sansValeur
|
||||||
|
? <span className="text-muted" style={{ fontWeight: 400, fontStyle: 'italic' }} title="Compte référencé dans Paramètres, sans solde saisi">Non renseigné</span>
|
||||||
|
: fmtEUR(ligne.valeur_eur)}
|
||||||
|
</td>
|
||||||
|
<td style={{ textAlign: 'right', color: 'var(--text-muted)' }} title={PLUS_MOINS_VALUE_TITLE}>—</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<>
|
||||||
|
<tr style={{ cursor: hasChildren ? 'pointer' : 'default' }} onClick={() => hasChildren && toggle(key)}>
|
||||||
|
<td style={{ paddingLeft: 10 + depth * 22 }}>
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontWeight: depth === 0 ? 600 : 500 }}>
|
||||||
|
{hasChildren ? <IconChevron open={isOpen} /> : <span style={{ width: 13, display: 'inline-block' }} />}
|
||||||
|
{node.nom}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="text-muted">{pctText}</td>
|
||||||
|
<td className="text-muted"></td>
|
||||||
|
<td style={{ textAlign: 'right', fontWeight: 700 }} title={valeur.title}>{valeur.text}</td>
|
||||||
|
<td style={{ textAlign: 'right', color: 'var(--text-muted)' }} title={PLUS_MOINS_VALUE_TITLE}>—</td>
|
||||||
|
</tr>
|
||||||
|
{isOpen && node.lignes.map(l => (
|
||||||
|
<LigneRow key={`l:${l.id}`} ligne={l} depth={depth + 1} grandTotal={grandTotal} />
|
||||||
|
))}
|
||||||
|
{isOpen && node.enfants.map(child => (
|
||||||
|
<RegroupementRow key={`r:${child.id}`} node={child} depth={depth + 1} grandTotal={grandTotal} expanded={expanded} toggle={toggle} />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RegroupementsTable({ nodes, grandTotal, expanded, toggle }) {
|
||||||
|
return (
|
||||||
|
<div className="card" style={{ padding: 0, overflow: 'hidden' }}>
|
||||||
|
<table style={{ margin: 0 }}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Nom</th>
|
||||||
|
<th>Répartition</th>
|
||||||
|
<th>Détenteur</th>
|
||||||
|
<th style={{ textAlign: 'right' }}>Valeur</th>
|
||||||
|
<th style={{ textAlign: 'right' }}>+/- value</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{nodes.map(node => (
|
||||||
|
<RegroupementRow key={`r:${node.id}`} node={node} depth={0} grandTotal={grandTotal} expanded={expanded} toggle={toggle} />
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DashboardPatrimoine() {
|
||||||
|
const { activeId, activeView } = useInvestisseur();
|
||||||
|
const [data, setData] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [err, setErr] = useState(null);
|
||||||
|
const [expanded, setExpanded] = useState(() => new Set());
|
||||||
|
// Onglets Actifs/Passifs (30/09/26, demande Olivier avec capture d'écran à l'appui : "un
|
||||||
|
// système de tabulation avec actif et passif") — même composant .dr-tabs/.dr-tab que le
|
||||||
|
// reste de l'app (Frais.jsx, TaxReport.jsx, Plateformes.jsx…), état local simple comme sur
|
||||||
|
// Frais.jsx (pas de persistance URL/localStorage nécessaire ici, un seul dashboard).
|
||||||
|
const [activeTab, setActiveTab] = useState('actifs');
|
||||||
|
|
||||||
|
// Filtre par détenteur actif (30/09/26, retour d'Olivier : "La sélection d'un détenteur ne
|
||||||
|
// semble pas filtrer le patrimoine") — même convention que Dashboard.jsx/DashboardPe.jsx :
|
||||||
|
// activeView === 'all' ("toute la famille") envoie ?scope=all, sinon le header
|
||||||
|
// X-Investisseur-Id (ajouté automatiquement par api.js depuis activeId) filtre sur le seul
|
||||||
|
// investisseur actif. On refetch à chaque changement de profil actif.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeId && activeView !== 'all') return;
|
||||||
|
const scopeParams = activeView === 'all' ? { scope: 'all' } : undefined;
|
||||||
|
setLoading(true);
|
||||||
|
setErr(null);
|
||||||
|
api.get('/dashboard-patrimoine', scopeParams)
|
||||||
|
.then(setData)
|
||||||
|
.catch(e => setErr(e.message))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [activeId, activeView]);
|
||||||
|
|
||||||
|
const toggle = (key) => {
|
||||||
|
setExpanded(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(key)) next.delete(key); else next.add(key);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filtre "respect de l'accès utilisateur" (décision Olivier) : Crowdlending / Private Equity
|
||||||
|
// ne sont même pas affichés si l'utilisateur n'a pas accès au workspace correspondant — cf.
|
||||||
|
// backend routes/dashboardPatrimoine.js (access.crowdlending / access.private_equity).
|
||||||
|
//
|
||||||
|
// 30/09/26 : Crowdlending a été réintégré comme sous-regroupement de "Comptes
|
||||||
|
// d'investissement" (même statut que Private Equity, cf. claude/
|
||||||
|
// plan_regroupements_patrimoniaux.md) — il n'apparaît donc plus jamais au niveau racine de
|
||||||
|
// `data.regroupements`, seulement dans les `enfants` de "Comptes d'investissement". Le filtre
|
||||||
|
// par accès porte désormais uniquement sur les enfants, pour les deux noms.
|
||||||
|
const regroupements = useMemo(() => {
|
||||||
|
if (!data) return [];
|
||||||
|
return data.regroupements.map(r => ({
|
||||||
|
...r,
|
||||||
|
enfants: r.enfants.filter(c =>
|
||||||
|
(c.nom !== 'Private Equity' || data.access.private_equity) &&
|
||||||
|
(c.nom !== 'Crowdlending' || data.access.crowdlending)
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const actifs = regroupements.filter(r => r.type === 'actif');
|
||||||
|
const passifs = regroupements.filter(r => r.type === 'passif');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="topbar"><h2><PageIcon name="dashboard" />Tableau de bord — Patrimoine</h2></div>
|
||||||
|
|
||||||
|
{loading && <div className="text-muted" style={{ padding: '24px 0' }}>Chargement…</div>}
|
||||||
|
{err && <div className="error">{err}</div>}
|
||||||
|
|
||||||
|
{!loading && !err && data && (
|
||||||
|
<>
|
||||||
|
<div className="kpi-grid">
|
||||||
|
<KpiCard title="Total actif" value={fmtEUR(data.total_actif_eur)} note="Somme des soldes saisis et du capital investi" />
|
||||||
|
<KpiCard title="Total passif" value={fmtEUR(data.total_passif_eur)} />
|
||||||
|
<KpiCard
|
||||||
|
title="Patrimoine net"
|
||||||
|
value={fmtEUR(data.net_eur)}
|
||||||
|
color={data.net_eur >= 0 ? 'var(--success)' : 'var(--danger)'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="dr-tabs" style={{ marginTop: 24 }}>
|
||||||
|
<button className={`dr-tab${activeTab === 'actifs' ? ' active' : ''}`}
|
||||||
|
onClick={() => setActiveTab('actifs')}>Actifs</button>
|
||||||
|
{passifs.length > 0 && (
|
||||||
|
<button className={`dr-tab${activeTab === 'passifs' ? ' active' : ''}`}
|
||||||
|
onClick={() => setActiveTab('passifs')}>Passifs</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeTab === 'actifs' || passifs.length === 0 ? (
|
||||||
|
<RegroupementsTable nodes={actifs} grandTotal={data.total_actif_eur} expanded={expanded} toggle={toggle} />
|
||||||
|
) : (
|
||||||
|
<RegroupementsTable nodes={passifs} grandTotal={data.total_passif_eur} expanded={expanded} toggle={toggle} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p style={{ marginTop: 24, fontSize: '0.85em', color: 'var(--text-muted)' }}>
|
||||||
|
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).
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="1.8" /><circle cx="12" cy="12" r="1.8" /><circle cx="12" cy="19" r="1.8" /></svg>;
|
||||||
|
}
|
||||||
|
function IcoEdit() {
|
||||||
|
return <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" /><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4Z" /></svg>;
|
||||||
|
}
|
||||||
|
function IcoTrash() {
|
||||||
|
return <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="3 6 5 6 21 6" /><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" /><path d="M10 11v6M14 11v6" /><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" /></svg>;
|
||||||
|
}
|
||||||
|
function MenuBtn({ onClick, icon, label, danger = false }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '8px 14px', background: 'none', border: 'none', cursor: 'pointer', fontSize: 'var(--fs-sm)', color: danger ? 'var(--danger)' : 'var(--text)', textAlign: 'left' }}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.background = 'var(--surface-2)'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.background = 'none'}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
{icon}{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 <div className="topbar"><h2>Chargement…</h2></div>;
|
||||||
|
if (err) return <div className="error">{err}</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="topbar">
|
||||||
|
<h2>
|
||||||
|
<button className="ghost" onClick={() => navigate('/')} title="Retour au tableau de bord Patrimoine" style={{ marginRight: 8 }}>← Patrimoine</button>
|
||||||
|
{isNew ? 'Nouvelle ligne de patrimoine' : (ligne?.nom || '—')}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card" style={{ marginBottom: 20 }}>
|
||||||
|
<div className="kpi-grid">
|
||||||
|
<div className="kpi">
|
||||||
|
<div className="label">Valeur actuelle</div>
|
||||||
|
<div style={{ fontSize: '1.35rem', fontWeight: 700, marginTop: 4 }}>
|
||||||
|
{valeurActuelle != null ? fmtEUR(valeurActuelle) : '—'}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>
|
||||||
|
{nbTransactions > 0
|
||||||
|
? `Calculee depuis ${nbTransactions} transaction${nbTransactions > 1 ? 's' : ''}`
|
||||||
|
: 'Saisie directe (aucune transaction enregistrée) — onglet Paramétrage'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{ligne?.enveloppe_nom && (
|
||||||
|
<div className="kpi">
|
||||||
|
<div className="label">Enveloppe</div>
|
||||||
|
<div style={{ fontSize: '1.1rem', fontWeight: 600, marginTop: 4 }}>{ligne.enveloppe_nom}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{ligne?.institution_nom && (
|
||||||
|
<div className="kpi">
|
||||||
|
<div className="label">Établissement</div>
|
||||||
|
<div style={{ fontSize: '1.1rem', fontWeight: 600, marginTop: 4 }}>{ligne.institution_nom}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{ligne?.investisseur_nom && (
|
||||||
|
<div className="kpi">
|
||||||
|
<div className="label">Détenteur</div>
|
||||||
|
<div style={{ fontSize: '1.1rem', fontWeight: 600, marginTop: 4 }}>{ligne.investisseur_nom}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isNew && (
|
||||||
|
<div className="card" style={{ marginBottom: 20 }}>
|
||||||
|
<ValorisationPatrimoineChart rows={chartRows} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isNew && (
|
||||||
|
<div className="card" style={{ marginBottom: 20 }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
|
||||||
|
<h3 style={{ margin: 0 }}>Transactions</h3>
|
||||||
|
<button onClick={openNewTrans}>+ Ajouter une transaction</button>
|
||||||
|
</div>
|
||||||
|
{transactions.length === 0 ? (
|
||||||
|
<div className="text-muted">Aucune transaction enregistrée — la valeur actuelle repose sur la saisie directe (onglet Paramétrage ci-dessous).</div>
|
||||||
|
) : (
|
||||||
|
<table className="tip-table">
|
||||||
|
<thead><tr><th>Date</th><th>Type</th><th>Montant</th><th>Libellé</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{transactions.map(t => (
|
||||||
|
<tr key={t.id}>
|
||||||
|
<td>{fmtDate(t.date_operation)}</td>
|
||||||
|
<td>{TYPE_LABELS[t.type] || t.type}</td>
|
||||||
|
<td>{fmtEUR(t.montant)}</td>
|
||||||
|
<td>{t.libelle || '—'}</td>
|
||||||
|
<td style={{ textAlign: 'right' }}>
|
||||||
|
<button onClick={e => openMenuFor(e, t)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', padding: '4px 6px', borderRadius: 4 }}>
|
||||||
|
<IcoDots />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<h3 style={{ marginTop: 0 }}>Paramétrage</h3>
|
||||||
|
{saveErr && <div className="error" style={{ marginBottom: 12 }}>{saveErr}</div>}
|
||||||
|
<form onSubmit={handleSaveLigne}>
|
||||||
|
<div className="form-grid">
|
||||||
|
<label>Nom
|
||||||
|
<input value={form.nom} onChange={e => setForm(f => ({ ...f, nom: e.target.value }))} required />
|
||||||
|
</label>
|
||||||
|
<label>Enveloppe
|
||||||
|
<select value={form.enveloppe_id} onChange={e => setForm(f => ({ ...f, enveloppe_id: e.target.value }))} required>
|
||||||
|
<option value="">—</option>
|
||||||
|
{enveloppesFiltrees.map(e => <option key={e.id} value={e.id}>{e.nom}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>Établissement (optionnel)
|
||||||
|
<select value={form.institution_id} onChange={e => setForm(f => ({ ...f, institution_id: e.target.value }))}>
|
||||||
|
<option value="">—</option>
|
||||||
|
{institutions.map(i => <option key={i.id} value={i.id}>{i.nom}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>Détenteur (optionnel)
|
||||||
|
<select value={form.investisseur_id} onChange={e => setForm(f => ({ ...f, investisseur_id: e.target.value }))}>
|
||||||
|
<option value="">—</option>
|
||||||
|
{investisseurs.map(i => <option key={i.id} value={i.id}>{i.nom}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Valeur actuelle (€){nbTransactions > 0 && <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}> — calculee depuis les transactions, non modifiable ici</span>}
|
||||||
|
<input type="number" step="0.01" value={form.valeur_eur} disabled={nbTransactions > 0}
|
||||||
|
onChange={e => setForm(f => ({ ...f, valeur_eur: e.target.value }))} />
|
||||||
|
</label>
|
||||||
|
<label style={{ gridColumn: '1 / -1' }}>Notes
|
||||||
|
<textarea rows={3} value={form.notes} onChange={e => setForm(f => ({ ...f, notes: e.target.value }))} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 16 }}>
|
||||||
|
{!isNew
|
||||||
|
? <button type="button" className="danger" onClick={() => setDeleteLigneConfirm(true)}>Supprimer cette ligne</button>
|
||||||
|
: <span />}
|
||||||
|
<button type="submit" className="primary" disabled={pending}>
|
||||||
|
{pending ? 'Enregistrement…' : (isNew ? 'Créer' : 'Enregistrer')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{openMenu && (
|
||||||
|
<>
|
||||||
|
<div style={{ position: 'fixed', inset: 0, zIndex: 299 }} onClick={() => setOpenMenu(null)} />
|
||||||
|
<div style={{ position: 'fixed', left: openMenu.x, top: openMenu.y, transform: 'translateX(-100%) translateY(4px)', zIndex: 300, background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, boxShadow: '0 4px 20px rgba(0,0,0,0.15)', padding: '4px 0', minWidth: 160 }}>
|
||||||
|
<MenuBtn icon={<IcoEdit />} label="Modifier" onClick={() => openEditTrans(openMenu.trans)} />
|
||||||
|
<MenuBtn icon={<IcoTrash />} label="Supprimer" danger onClick={() => { setDeleteTransId(openMenu.trans.id); setOpenMenu(null); }} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={transModalOpen}
|
||||||
|
title={editingTransId ? 'Modifier la transaction' : 'Ajouter une transaction'}
|
||||||
|
onClose={() => setTransModalOpen(false)}
|
||||||
|
busy={pendingTrans}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<button className="ghost" onClick={() => setTransModalOpen(false)} disabled={pendingTrans}>Annuler</button>
|
||||||
|
<button className="primary" onClick={handleSaveTrans} disabled={pendingTrans}>{pendingTrans ? 'Enregistrement…' : 'Enregistrer'}</button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<form onSubmit={handleSaveTrans}>
|
||||||
|
<div className="form-grid">
|
||||||
|
<label>Type
|
||||||
|
<select value={transForm.type} onChange={e => setTransForm(f => ({ ...f, type: e.target.value }))}>
|
||||||
|
{Object.entries(TYPE_LABELS).map(([v, l]) => <option key={v} value={v}>{l}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>Date
|
||||||
|
<input type="date" value={transForm.date_operation} onChange={e => setTransForm(f => ({ ...f, date_operation: e.target.value }))} required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Montant (€){transForm.type !== 'reevaluation' && <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}> — toujours positif</span>}
|
||||||
|
<input type="number" step="0.01" value={transForm.montant} onChange={e => setTransForm(f => ({ ...f, montant: e.target.value }))} required />
|
||||||
|
</label>
|
||||||
|
<label>Libellé (optionnel)
|
||||||
|
<input value={transForm.libelle} onChange={e => setTransForm(f => ({ ...f, libelle: e.target.value }))} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<ConfirmModal
|
||||||
|
open={!!deleteTransId}
|
||||||
|
title="Supprimer la transaction"
|
||||||
|
message="Cette transaction sera définitivement supprimée."
|
||||||
|
onConfirm={handleDeleteTrans}
|
||||||
|
onCancel={() => setDeleteTransId(null)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ConfirmModal
|
||||||
|
open={deleteLigneConfirm}
|
||||||
|
title="Supprimer cette ligne de patrimoine"
|
||||||
|
message="Cette ligne et toutes ses transactions seront définitivement supprimées."
|
||||||
|
onConfirm={handleDeleteLigne}
|
||||||
|
onCancel={() => setDeleteLigneConfirm(false)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
|
// 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
|
// 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).
|
// 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
|
// 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
|
// 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',
|
slug: 'private-equity', nom: 'Private Equity', libelleMenu: 'Private Equity',
|
||||||
description: 'Suivi des investissements de type 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 };
|
const emptyForm = { slug: '', nom: '', libelleMenu: '', description: '', type: '', actifGlobal: true, ordre: 0 };
|
||||||
@@ -162,10 +166,12 @@ function WorkspaceFormModal({ open, onClose, onSaved, workspace }) {
|
|||||||
<option value="">— Choisir —</option>
|
<option value="">— Choisir —</option>
|
||||||
<option value="crowdlending">Crowdlending</option>
|
<option value="crowdlending">Crowdlending</option>
|
||||||
<option value="private_equity">Private Equity</option>
|
<option value="private_equity">Private Equity</option>
|
||||||
|
<option value="patrimoine">Patrimoine</option>
|
||||||
</select>
|
</select>
|
||||||
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>
|
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>
|
||||||
Détermine les pages affichées pour cet espace (Crowdlending ou Private Equity) — non
|
Détermine les pages affichées pour cet espace (Crowdlending, Private Equity ou
|
||||||
modifiable après création. Un seul espace de type Crowdlending peut exister.
|
Patrimoine) — non modifiable après création. Un seul espace de type Crowdlending et
|
||||||
|
un seul de type Patrimoine peuvent exister.
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ marginBottom: 12 }}>
|
<div style={{ marginBottom: 12 }}>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const EXONERATION_LABELS = {
|
|||||||
aucune: 'Aucune',
|
aucune: 'Aucune',
|
||||||
pfnl_5ans: 'PFnl 5 ans',
|
pfnl_5ans: 'PFnl 5 ans',
|
||||||
};
|
};
|
||||||
const EMPTY_COMPTE = { nom: '', investisseur_id: null, institution_id: null, banque: '', enveloppe_id: null, exoneration_fiscale: 'aucune', compte_reglement_par_defaut: false };
|
const EMPTY_COMPTE = { nom: '', investisseur_id: null, institution_id: null, banque: '', enveloppe_id: null, exoneration_fiscale: 'aucune', compte_reglement_par_defaut: false, solde_eur: null };
|
||||||
|
|
||||||
// Référentiel institutions financières (Phase 2, 26/09/26) — logos servis au même endroit
|
// Référentiel institutions financières (Phase 2, 26/09/26) — logos servis au même endroit
|
||||||
// que ceux des plateformes (/api/logos/:filename), cf. institutionsReferentiel.js.
|
// que ceux des plateformes (/api/logos/:filename), cf. institutionsReferentiel.js.
|
||||||
@@ -80,6 +80,16 @@ function CompteFormFields({ state, setter, investisseurs, institutions, envelopp
|
|||||||
Rattachement au référentiel (Admin > Enveloppes & supports).
|
Rattachement au référentiel (Admin > Enveloppes & supports).
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Solde (€)</label>
|
||||||
|
<input type="number" step="0.01" value={state.solde_eur ?? ''}
|
||||||
|
onChange={e => setter({ ...state, solde_eur: e.target.value === '' ? null : Number(e.target.value) })}
|
||||||
|
placeholder="Non renseigné" />
|
||||||
|
<p style={{ margin: '4px 0 0', fontSize: 11, color: 'var(--text-muted)' }}>
|
||||||
|
Saisie manuelle — utilisé pour la vue patrimoine consolidée (espace de travail
|
||||||
|
Patrimoine).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label>Détenteur</label>
|
<label>Détenteur</label>
|
||||||
<select value={state.investisseur_id ?? ''}
|
<select value={state.investisseur_id ?? ''}
|
||||||
@@ -157,6 +167,7 @@ export default function ComptesSection() {
|
|||||||
investisseur_id: newCompte.investisseur_id ? Number(newCompte.investisseur_id) : null,
|
investisseur_id: newCompte.investisseur_id ? Number(newCompte.investisseur_id) : null,
|
||||||
exoneration_fiscale: newCompte.exoneration_fiscale ?? 'aucune',
|
exoneration_fiscale: newCompte.exoneration_fiscale ?? 'aucune',
|
||||||
compte_reglement_par_defaut: !!newCompte.compte_reglement_par_defaut,
|
compte_reglement_par_defaut: !!newCompte.compte_reglement_par_defaut,
|
||||||
|
solde_eur: newCompte.solde_eur ?? null,
|
||||||
});
|
});
|
||||||
setShowNew(false); setNewCompte(EMPTY_COMPTE); reload();
|
setShowNew(false); setNewCompte(EMPTY_COMPTE); reload();
|
||||||
} catch (ex) { setErr(ex.message); }
|
} catch (ex) { setErr(ex.message); }
|
||||||
@@ -173,6 +184,7 @@ export default function ComptesSection() {
|
|||||||
investisseur_id: editCompte.investisseur_id ? Number(editCompte.investisseur_id) : null,
|
investisseur_id: editCompte.investisseur_id ? Number(editCompte.investisseur_id) : null,
|
||||||
exoneration_fiscale: editCompte.exoneration_fiscale ?? 'aucune',
|
exoneration_fiscale: editCompte.exoneration_fiscale ?? 'aucune',
|
||||||
compte_reglement_par_defaut: !!editCompte.compte_reglement_par_defaut,
|
compte_reglement_par_defaut: !!editCompte.compte_reglement_par_defaut,
|
||||||
|
solde_eur: editCompte.solde_eur ?? null,
|
||||||
});
|
});
|
||||||
setEditCompte(null); reload();
|
setEditCompte(null); reload();
|
||||||
} catch (ex) { setErr(ex.message); }
|
} catch (ex) { setErr(ex.message); }
|
||||||
@@ -180,7 +192,7 @@ export default function ComptesSection() {
|
|||||||
|
|
||||||
const openEdit = (c) => {
|
const openEdit = (c) => {
|
||||||
setErr(null);
|
setErr(null);
|
||||||
setEditCompte({ id: c.id, nom: c.nom, banque: c.banque || '', institution_id: c.institution_id ?? null, enveloppe_id: c.enveloppe_id ?? null, investisseur_id: c.investisseur_id ?? null, exoneration_fiscale: c.exoneration_fiscale ?? 'aucune', compte_reglement_par_defaut: !!c.compte_reglement_par_defaut });
|
setEditCompte({ id: c.id, nom: c.nom, banque: c.banque || '', institution_id: c.institution_id ?? null, enveloppe_id: c.enveloppe_id ?? null, investisseur_id: c.investisseur_id ?? null, exoneration_fiscale: c.exoneration_fiscale ?? 'aucune', compte_reglement_par_defaut: !!c.compte_reglement_par_defaut, solde_eur: c.solde_eur ?? null });
|
||||||
};
|
};
|
||||||
|
|
||||||
const del = (c) => {
|
const del = (c) => {
|
||||||
@@ -220,6 +232,7 @@ export default function ComptesSection() {
|
|||||||
{multiDetenteur && <th style={{ width: '18%' }}>Détenteur</th>}
|
{multiDetenteur && <th style={{ width: '18%' }}>Détenteur</th>}
|
||||||
<th style={{ width: '14%' }}>Banque</th>
|
<th style={{ width: '14%' }}>Banque</th>
|
||||||
<th style={{ width: '16%' }}>Enveloppe</th>
|
<th style={{ width: '16%' }}>Enveloppe</th>
|
||||||
|
<th style={{ width: '11%' }}>Solde</th>
|
||||||
<th style={{ width: '11%' }}>Défaut</th>
|
<th style={{ width: '11%' }}>Défaut</th>
|
||||||
<th style={{ width: '9%' }}>Exonération</th>
|
<th style={{ width: '9%' }}>Exonération</th>
|
||||||
<th style={{ width: 80 }} />
|
<th style={{ width: 80 }} />
|
||||||
@@ -227,7 +240,7 @@ export default function ComptesSection() {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{comptes.length === 0 && (
|
{comptes.length === 0 && (
|
||||||
<tr><td colSpan={multiDetenteur ? 7 : 6} className="text-muted" style={{ textAlign: 'center', fontStyle: 'italic' }}>
|
<tr><td colSpan={multiDetenteur ? 8 : 7} className="text-muted" style={{ textAlign: 'center', fontStyle: 'italic' }}>
|
||||||
Aucun compte défini.
|
Aucun compte défini.
|
||||||
</td></tr>
|
</td></tr>
|
||||||
)}
|
)}
|
||||||
@@ -249,6 +262,11 @@ export default function ComptesSection() {
|
|||||||
) : (c.banque || <span className="text-muted">—</span>)}
|
) : (c.banque || <span className="text-muted">—</span>)}
|
||||||
</td>
|
</td>
|
||||||
<td>{c.enveloppe_nom || <span className="text-muted">—</span>}</td>
|
<td>{c.enveloppe_nom || <span className="text-muted">—</span>}</td>
|
||||||
|
<td>
|
||||||
|
{c.solde_eur != null
|
||||||
|
? c.solde_eur.toLocaleString('fr-FR', { style: 'currency', currency: 'EUR' })
|
||||||
|
: <span className="text-muted">—</span>}
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{c.compte_reglement_par_defaut ? (
|
{c.compte_reglement_par_defaut ? (
|
||||||
<span style={{ color: 'var(--primary)', fontWeight: 600, fontSize: 'var(--fs-sm)' }}>✓ Défaut</span>
|
<span style={{ color: 'var(--primary)', fontWeight: 600, fontSize: 'var(--fs-sm)' }}>✓ Défaut</span>
|
||||||
|
|||||||
@@ -905,6 +905,35 @@ a { color: var(--primary); }
|
|||||||
}
|
}
|
||||||
.card h3 { font-size: 1rem; font-weight: 600; margin: 0 0 14px; }
|
.card h3 { font-size: 1rem; font-weight: 600; margin: 0 0 14px; }
|
||||||
|
|
||||||
|
/* Modale "Compléter mon patrimoine" (30/09/26) — grille de tuiles icone + titre + description */
|
||||||
|
.patrimoine-ajout-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(210px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.patrimoine-ajout-tile {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
text-align: left;
|
||||||
|
gap: 6px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color .15s, box-shadow .15s, transform .1s;
|
||||||
|
}
|
||||||
|
.patrimoine-ajout-tile:hover:not(:disabled) {
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 3px 12px rgba(var(--primary-rgb),.18);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
.patrimoine-ajout-tile:disabled { opacity: .5; cursor: not-allowed; }
|
||||||
|
.patrimoine-ajout-tile-icon { color: var(--primary); }
|
||||||
|
.patrimoine-ajout-tile-title { font-weight: 600; font-size: var(--fs-base); color: var(--text); }
|
||||||
|
.patrimoine-ajout-tile-desc { font-size: var(--fs-xs); color: var(--text-muted); line-height: 1.35; }
|
||||||
|
|
||||||
/* KPI grid */
|
/* KPI grid */
|
||||||
.kpi-grid {
|
.kpi-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
Reference in new issue
Block a user