From efa5de79b8afb03d6ae40120cfc3bd0144edf10f Mon Sep 17 00:00:00 2001
From: Olivier
Date: Mon, 28 Sep 2026 14:30:01 +0200
Subject: [PATCH] Notion de Regroupements Patrimoine
---
backend/src/db/index.js | 156 +++++++++
backend/src/routes/emprunts.js | 2 +
backend/src/routes/enveloppesReferentiel.js | 49 ++-
.../src/routes/regroupementsPatrimoniaux.js | 159 +++++++++
backend/src/server.js | 2 +
frontend/src/pages/AdminEnveloppes.jsx | 315 +++++++++++++++++-
6 files changed, 661 insertions(+), 22 deletions(-)
create mode 100644 backend/src/routes/regroupementsPatrimoniaux.js
diff --git a/backend/src/db/index.js b/backend/src/db/index.js
index e08c186..cbe478f 100644
--- a/backend/src/db/index.js
+++ b/backend/src/db/index.js
@@ -4286,4 +4286,160 @@ db.exec('CREATE INDEX IF NOT EXISTS idx_emprunts_inv ON emprunts(investisseur_i
}
}
+
+// ── Chantier "Regroupements patrimoniaux" (28/09/26 (suite), demande Olivier) ──────────────
+// Objectif : présenter une vue patrimoine consolidée par grandes familles d'actifs (Immobilier,
+// Comptes courants, Comptes d'épargne, Crypto, Comptes d'investissement, Crowdlending, Autres
+// actifs) et par passif (Emprunts) — orthogonal aux 15 catégories existantes de
+// categories_enveloppes, qui servent à organiser le catalogue admin, pas à présenter une vue
+// consolidée (ex. la catégorie "Financement participatif / private equity" doit se scinder
+// entre Crowdlending et Private Equity ; "Immobilier papier" et "Immobilier physique" doivent
+// au contraire fusionner). Cadrage validé par Olivier via AskUserQuestion (28/09/26 (suite)) :
+// - Rattachement au niveau ENVELOPPE (pas catégorie), exclusif — une enveloppe = un seul
+// regroupement, pour ne jamais compter un actif deux fois dans un futur total consolidé.
+// - Hiérarchie générique à deux niveaux (parent_id auto-référencé), pas un cas particulier
+// Private Equity — réutilisable pour d'autres sous-catégories futures (ex. "Immobilier" >
+// "Locatif meublé").
+// - Crowdlending et Private Equity ne rattachent PAS les investissements individuels
+// (investissements / investissements_pe) ligne à ligne : la gestion reste dans leurs
+// workspaces respectifs, qui restent la source de vérité — seul un total consolidé,
+// calculé à la volée, viendra s'afficher sous ces deux regroupements. Cette vue consolidée
+// elle-même n'est PAS implémentée dans ce chantier (dépend de la Phase 3b / vue patrimoine
+// transverse, non commencée) — seule la classification (ce fichier + l'admin) l'est.
+// - Emprunts : regroupement séparé de type 'passif', jamais mélangé aux 7 regroupements
+// d'actifs. Le lien emprunt → actif financé (ex. crédit immobilier → bien précis) demandé
+// par Olivier dépend de la future table "ligne de patrimoine" (Phase 3b, en pause) —
+// aucune table n'existe aujourd'hui pour représenter une position individuelle d'actif
+// direct (une "Résidence principale" dans enveloppes_referentiel est une ligne de catalogue
+// partagée entre tous les utilisateurs, pas *le* bien d'Olivier) : ce lien n'est donc PAS
+// implémenté dans ce chantier, décision explicite d'Olivier via AskUserQuestion
+// ("Implémenter la classification seule maintenant").
+db.exec(`
+ CREATE TABLE IF NOT EXISTS regroupements_patrimoniaux (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ nom TEXT NOT NULL UNIQUE,
+ parent_id INTEGER REFERENCES regroupements_patrimoniaux(id) ON DELETE SET NULL,
+ type TEXT NOT NULL DEFAULT 'actif',
+ ordre_affichage INTEGER NOT NULL DEFAULT 0,
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
+ )
+`);
+db.exec('CREATE INDEX IF NOT EXISTS idx_regroupatr_parent ON regroupements_patrimoniaux(parent_id)');
+
+// Seed idempotent (n'insère que si la table est vide — l'admin reste libre de renommer/
+// réorganiser ensuite, comme pour les catégories). 7 regroupements racine "actif" + 1
+// "passif" (Passif, pour les emprunts), puis 1 sous-regroupement "Private Equity" rattaché à
+// "Comptes d'investissement" (inséré dans un second temps, une fois l'id parent connu).
+if (db.prepare('SELECT COUNT(*) AS n FROM regroupements_patrimoniaux').get().n === 0) {
+ const REGROUPEMENTS_RACINE = [
+ { nom: 'Immobilier', type: 'actif', ordre: 1 },
+ { nom: 'Comptes courants', type: 'actif', ordre: 2 },
+ { nom: "Comptes d'épargne", type: 'actif', ordre: 3 },
+ { nom: 'Crypto', type: 'actif', ordre: 4 },
+ { nom: "Comptes d'investissement", type: 'actif', ordre: 5 },
+ { nom: 'Crowdlending', type: 'actif', ordre: 6 },
+ { nom: 'Autres actifs', type: 'actif', ordre: 7 },
+ { nom: 'Passif', type: 'passif', ordre: 8 },
+ ];
+ const insRoot = db.prepare(
+ 'INSERT INTO regroupements_patrimoniaux (nom, type, ordre_affichage) VALUES (?, ?, ?)'
+ );
+ const getByNom = db.prepare('SELECT id FROM regroupements_patrimoniaux WHERE nom = ?');
+ db.transaction(() => {
+ for (const r of REGROUPEMENTS_RACINE) insRoot.run(r.nom, r.type, r.ordre);
+ const comptesInv = getByNom.get("Comptes d'investissement");
+ db.prepare(
+ 'INSERT INTO regroupements_patrimoniaux (nom, parent_id, type, ordre_affichage) VALUES (?, ?, ?, ?)'
+ ).run('Private Equity', comptesInv.id, 'actif', 1);
+ })();
+}
+
+// Colonne de rattachement sur enveloppes_referentiel — ALTER gardé par PRAGMA table_info,
+// même patron que eligible_compte/compte_reglement_par_defaut ci-dessus.
+{
+ const cols = db.prepare('PRAGMA table_info(enveloppes_referentiel)').all().map(c => c.name);
+ if (!cols.includes('regroupement_patrimonial_id')) {
+ db.exec('ALTER TABLE enveloppes_referentiel ADD COLUMN regroupement_patrimonial_id INTEGER REFERENCES regroupements_patrimoniaux(id) ON DELETE SET NULL');
+ }
+}
+
+// Rattachement des 46 enveloppes du catalogue à leur regroupement — backfill TOUJOURS exécuté
+// (comme le backfill enveloppe_id ci-dessus), gardé par regroupement_patrimonial_id IS NULL :
+// ne touche jamais une enveloppe déjà rattachée, manuellement (admin) ou par une exécution
+// précédente de ce même bloc (idempotent). Mapping construit en croisant les 46 noms exacts du
+// catalogue (SEED_ENVELOPPES + EXTRA_ENVELOPPES ci-dessus) avec le regroupement demandé par
+// Olivier ; répartition documentée dans claude/plan_regroupements_patrimoniaux.md (doc projet).
+{
+ const ENVELOPPE_REGROUPEMENT = {
+ 'Comptes courants': [
+ 'Compte courant non rémunéré', 'Compte courant rémunéré',
+ 'Compte rémunéré nouvelle génération (fintech)',
+ ],
+ "Comptes d'épargne": [
+ 'Livret A', 'LDDS', 'LEP', 'Livret Jeune', 'CEL', 'PEL',
+ 'Livret bancaire non réglementé', 'Compte à terme (CAT)', 'Livret solidaire',
+ ],
+ "Comptes d'investissement": [
+ 'Assurance-vie monosupport (fonds euros)', 'Assurance-vie multisupport',
+ 'Contrat de capitalisation', 'Assurance-vie luxembourgeoise',
+ 'PER individuel', 'PER collectif / obligatoire (PERCOL, article 83)', 'PEE',
+ 'PEA', 'PEA-PME', 'Compte-titres ordinaire (CTO)',
+ ],
+ 'Private Equity': [
+ 'Crowdequity', 'FCPI / FIP', 'Participation directe non cotée (startups/PME)',
+ 'Dette privée (fonds)', 'Capital-investissement réservé (FPCI / SLP / FPS)',
+ 'Infrastructures non cotées',
+ ],
+ 'Crowdlending': [
+ 'Crowdlending (prêts aux entreprises/PME)', 'Crowdfunding immobilier',
+ ],
+ 'Immobilier': [
+ 'SCPI (détenue en direct ou à crédit)', 'OPCI', 'SCI',
+ 'SCPI en démembrement (nue-propriété / usufruit temporaire)',
+ 'Résidence principale', 'Résidence secondaire',
+ 'Immobilier locatif (détention directe)', 'Immobilier locatif meublé (LMNP/LMP)',
+ ],
+ 'Crypto': [
+ 'Compte crypto (exchange)', 'Wallet crypto (auto-conservation)',
+ 'Wallet crypto (conservation par un tiers)',
+ ],
+ 'Autres actifs': [
+ 'Métaux précieux physiques', 'Objets de collection',
+ 'Girardin (industriel / social)',
+ 'Groupement Foncier Forestier (GFF)', 'Groupement Foncier Viticole (GFV)',
+ ],
+ };
+ const getRegroupement = db.prepare('SELECT id FROM regroupements_patrimoniaux WHERE nom = ?');
+ const setRegroupement = db.prepare(
+ 'UPDATE enveloppes_referentiel SET regroupement_patrimonial_id = ? WHERE nom = ? AND regroupement_patrimonial_id IS NULL'
+ );
+ db.transaction(() => {
+ for (const [regroupementNom, enveloppeNoms] of Object.entries(ENVELOPPE_REGROUPEMENT)) {
+ const regroupement = getRegroupement.get(regroupementNom);
+ if (!regroupement) continue;
+ for (const envNom of enveloppeNoms) setRegroupement.run(regroupement.id, envNom);
+ }
+ })();
+}
+
+// Colonne de rattachement sur emprunts — ajoutée pour rester cohérente avec
+// enveloppes_referentiel et permettre une subdivision future du passif (ex. "Emprunt
+// immobilier" vs "Emprunt à la consommation" comme enfants de "Passif"), même s'il n'existe
+// aujourd'hui qu'une seule valeur possible. Backfill toujours exécuté, gardé par IS NULL.
+{
+ const cols = db.prepare('PRAGMA table_info(emprunts)').all().map(c => c.name);
+ if (!cols.includes('regroupement_patrimonial_id')) {
+ db.exec('ALTER TABLE emprunts ADD COLUMN regroupement_patrimonial_id INTEGER REFERENCES regroupements_patrimoniaux(id) ON DELETE SET NULL');
+ }
+}
+{
+ const passif = db.prepare("SELECT id FROM regroupements_patrimoniaux WHERE nom = 'Passif'").get();
+ if (passif) {
+ db.prepare(
+ 'UPDATE emprunts SET regroupement_patrimonial_id = ? WHERE regroupement_patrimonial_id IS NULL'
+ ).run(passif.id);
+ }
+}
+
export default db;
diff --git a/backend/src/routes/emprunts.js b/backend/src/routes/emprunts.js
index b89aa1d..57e3dc4 100644
--- a/backend/src/routes/emprunts.js
+++ b/backend/src/routes/emprunts.js
@@ -31,6 +31,7 @@ router.get('/', (req, res) => {
const rows = db.prepare(`
SELECT e.id, e.nom, e.investisseur_id, e.institution_id, e.montant_emprunte, e.capital_restant_du,
e.taux_nominal, e.date_debut, e.duree_mois, e.mensualite, e.notes, e.created_at,
+ e.regroupement_patrimonial_id, rp.nom AS regroupement_patrimonial_nom,
inv.nom AS investisseur_nom, inv.prenom AS investisseur_prenom,
inv.type AS investisseur_type, inv.type_fiscal AS investisseur_type_fiscal,
ir.nom AS institution_nom, ir.domiciliation AS institution_domiciliation,
@@ -38,6 +39,7 @@ router.get('/', (req, res) => {
FROM emprunts e
LEFT JOIN investisseurs inv ON inv.id = e.investisseur_id
LEFT JOIN institutions_referentiel ir ON ir.id = e.institution_id
+ LEFT JOIN regroupements_patrimoniaux rp ON rp.id = e.regroupement_patrimonial_id
WHERE e.user_id = ?
ORDER BY e.nom
`).all(req.user.id);
diff --git a/backend/src/routes/enveloppesReferentiel.js b/backend/src/routes/enveloppesReferentiel.js
index 52a0e84..98bf3a0 100644
--- a/backend/src/routes/enveloppesReferentiel.js
+++ b/backend/src/routes/enveloppesReferentiel.js
@@ -25,13 +25,14 @@ const router = Router();
// Toutes ces routes sont montées sous requireAdmin dans server.js
const Schema = z.object({
- nom: z.string().min(1).max(200),
- description: z.string().nullable().optional(),
- plafond_eur: z.number().positive().nullable().optional(),
- regime_fiscal: z.string().nullable().optional(),
- eligible_compte: z.boolean().optional().default(false),
- categories_ids: z.array(z.number().int()).optional().default([]),
- supports_ids: z.array(z.number().int()).optional().default([]),
+ nom: z.string().min(1).max(200),
+ description: z.string().nullable().optional(),
+ plafond_eur: z.number().positive().nullable().optional(),
+ regime_fiscal: z.string().nullable().optional(),
+ eligible_compte: z.boolean().optional().default(false),
+ regroupement_patrimonial_id: z.number().int().positive().nullable().optional(),
+ categories_ids: z.array(z.number().int()).optional().default([]),
+ supports_ids: z.array(z.number().int()).optional().default([]),
});
// ── Helpers ────────────────────────────────────────────────────────────────
@@ -74,6 +75,24 @@ function attachSupports(rows) {
return rows.map(r => ({ ...r, supports: map[r.id] || [] }));
}
+/** Regroupement patrimonial (+ parent éventuel) attaché à chaque enveloppe */
+function attachRegroupement(rows) {
+ if (rows.length === 0) return rows;
+ const ids = [...new Set(rows.map(r => r.regroupement_patrimonial_id).filter(Boolean))];
+ if (ids.length === 0) return rows.map(r => ({ ...r, regroupement_patrimonial: null }));
+ const regs = db.prepare(`
+ SELECT r.id, r.nom, r.type, r.parent_id, p.nom AS parent_nom
+ FROM regroupements_patrimoniaux r
+ LEFT JOIN regroupements_patrimoniaux p ON p.id = r.parent_id
+ WHERE r.id IN (${ids.map(() => '?').join(',')})
+ `).all(...ids);
+ const map = Object.fromEntries(regs.map(r => [r.id, r]));
+ return rows.map(r => ({
+ ...r,
+ regroupement_patrimonial: r.regroupement_patrimonial_id ? (map[r.regroupement_patrimonial_id] || null) : null,
+ }));
+}
+
function saveCategories(enveloppeId, categoriesIds) {
db.prepare('DELETE FROM referentiel_categories_enveloppes WHERE enveloppe_id = ?').run(enveloppeId);
if (categoriesIds && categoriesIds.length > 0) {
@@ -93,7 +112,7 @@ function saveSupports(enveloppeId, supportsIds) {
// ── GET /api/enveloppes-referentiel ─────────────────────────────────────────
router.get('/', (_req, res) => {
let rows = db.prepare('SELECT * FROM enveloppes_referentiel ORDER BY nom').all();
- rows = attachSupports(attachCategories(rows));
+ rows = attachRegroupement(attachSupports(attachCategories(rows)));
res.json(rows);
});
@@ -102,7 +121,7 @@ router.get('/:id', (req, res, next) => {
try {
const row = db.prepare('SELECT * FROM enveloppes_referentiel WHERE id = ?').get(req.params.id);
if (!row) throw new HttpError(404, 'Enveloppe introuvable');
- const [enriched] = attachSupports(attachCategories([row]));
+ const [enriched] = attachRegroupement(attachSupports(attachCategories([row])));
res.json(enriched);
} catch (e) { next(e); }
});
@@ -115,9 +134,9 @@ router.post('/', (req, res, next) => {
if (existing) throw new HttpError(409, `Une enveloppe "${data.nom}" existe déjà`);
const r = db.prepare(`
- INSERT INTO enveloppes_referentiel (nom, description, plafond_eur, regime_fiscal, eligible_compte, updated_at)
- VALUES (?,?,?,?,?, datetime('now'))
- `).run(data.nom, data.description || null, data.plafond_eur ?? null, data.regime_fiscal || null, data.eligible_compte ? 1 : 0);
+ INSERT INTO enveloppes_referentiel (nom, description, plafond_eur, regime_fiscal, eligible_compte, regroupement_patrimonial_id, updated_at)
+ VALUES (?,?,?,?,?,?, datetime('now'))
+ `).run(data.nom, data.description || null, data.plafond_eur ?? null, data.regime_fiscal || null, data.eligible_compte ? 1 : 0, data.regroupement_patrimonial_id ?? null);
const enveloppeId = r.lastInsertRowid;
db.transaction(() => {
@@ -126,7 +145,7 @@ router.post('/', (req, res, next) => {
})();
const row = db.prepare('SELECT * FROM enveloppes_referentiel WHERE id = ?').get(enveloppeId);
- const [enriched] = attachSupports(attachCategories([row]));
+ const [enriched] = attachRegroupement(attachSupports(attachCategories([row])));
res.status(201).json(enriched);
} catch (e) { next(e); }
});
@@ -145,9 +164,9 @@ router.put('/:id', (req, res, next) => {
db.prepare(`
UPDATE enveloppes_referentiel
- SET nom=?, description=?, plafond_eur=?, regime_fiscal=?, eligible_compte=?, updated_at=datetime('now')
+ SET nom=?, description=?, plafond_eur=?, regime_fiscal=?, eligible_compte=?, regroupement_patrimonial_id=?, updated_at=datetime('now')
WHERE id=?
- `).run(data.nom, data.description || null, data.plafond_eur ?? null, data.regime_fiscal || null, data.eligible_compte ? 1 : 0, row.id);
+ `).run(data.nom, data.description || null, data.plafond_eur ?? null, data.regime_fiscal || null, data.eligible_compte ? 1 : 0, data.regroupement_patrimonial_id ?? null, row.id);
db.transaction(() => {
saveCategories(row.id, data.categories_ids);
diff --git a/backend/src/routes/regroupementsPatrimoniaux.js b/backend/src/routes/regroupementsPatrimoniaux.js
new file mode 100644
index 0000000..3a43c3c
--- /dev/null
+++ b/backend/src/routes/regroupementsPatrimoniaux.js
@@ -0,0 +1,159 @@
+import { Router } from 'express';
+import { z } from 'zod';
+import db from '../db/index.js';
+import { HttpError } from '../middleware/errorHandler.js';
+
+/**
+ * routes/regroupementsPatrimoniaux.js — Référentiel admin des "regroupements patrimoniaux"
+ * (chantier du 28/09/26 (suite), demande Olivier — présenter une vue patrimoine consolidée par
+ * grandes familles d'actifs : Immobilier, Comptes courants, Comptes d'épargne, Crypto, Comptes
+ * d'investissement, Crowdlending, Autres actifs — et un regroupement "Passif" pour les emprunts).
+ *
+ * Orthogonal à categories_enveloppes : les catégories organisent le catalogue admin, ce
+ * référentiel sert à présenter une vue consolidée (une même catégorie, ex. "Financement
+ * participatif / private equity", peut donc se scinder entre plusieurs regroupements ici).
+ *
+ * Hiérarchie générique à deux niveaux (parent_id auto-référencé) — cadrage validé par Olivier
+ * via AskUserQuestion (28/09/26 (suite)) : réutilisable pour d'autres sous-catégories futures,
+ * pas un cas particulier de "Private Equity" (aujourd'hui seul enfant existant, rattaché à
+ * "Comptes d'investissement").
+ *
+ * Rattaché par enveloppes_referentiel.regroupement_patrimonial_id (catalogue, exclusif) et
+ * emprunts.regroupement_patrimonial_id (toujours "Passif" pour l'instant). Voir
+ * claude/plan_regroupements_patrimoniaux.md (doc projet) pour le cadrage complet et les
+ * décisions prises avec Olivier.
+ */
+
+const router = Router();
+// Toutes ces routes sont montées sous requireAdmin dans server.js
+
+const Schema = z.object({
+ nom: z.string().min(1).max(200),
+ parent_id: z.number().int().positive().nullable().optional(),
+ type: z.enum(['actif', 'passif']).optional().default('actif'),
+ ordre_affichage: z.number().int().optional().default(0),
+});
+
+function withCounts(rows) {
+ if (rows.length === 0) return rows;
+ const ids = rows.map(r => r.id);
+ const placeholders = ids.map(() => '?').join(',');
+ const nbEnveloppes = db.prepare(`
+ SELECT regroupement_patrimonial_id AS id, COUNT(*) AS n
+ FROM enveloppes_referentiel
+ WHERE regroupement_patrimonial_id IN (${placeholders})
+ GROUP BY regroupement_patrimonial_id
+ `).all(...ids);
+ const nbEmprunts = db.prepare(`
+ SELECT regroupement_patrimonial_id AS id, COUNT(*) AS n
+ FROM emprunts
+ WHERE regroupement_patrimonial_id IN (${placeholders})
+ GROUP BY regroupement_patrimonial_id
+ `).all(...ids);
+ const nbEnfants = db.prepare(`
+ SELECT parent_id AS id, COUNT(*) AS n
+ FROM regroupements_patrimoniaux
+ WHERE parent_id IN (${placeholders})
+ GROUP BY parent_id
+ `).all(...ids);
+ const mapOf = list => Object.fromEntries(list.map(r => [r.id, r.n]));
+ const mEnv = mapOf(nbEnveloppes), mEmp = mapOf(nbEmprunts), mEnf = mapOf(nbEnfants);
+ return rows.map(r => ({
+ ...r,
+ nb_enveloppes: mEnv[r.id] || 0,
+ nb_emprunts: mEmp[r.id] || 0,
+ nb_enfants: mEnf[r.id] || 0,
+ }));
+}
+
+/* GET /api/regroupements-patrimoniaux — liste complète, avec nom du parent */
+router.get('/', (_req, res) => {
+ const rows = db.prepare(`
+ SELECT r.id, r.nom, r.parent_id, r.type, r.ordre_affichage, r.created_at, r.updated_at,
+ p.nom AS parent_nom
+ FROM regroupements_patrimoniaux r
+ LEFT JOIN regroupements_patrimoniaux p ON p.id = r.parent_id
+ ORDER BY r.ordre_affichage, r.nom
+ `).all();
+ res.json(withCounts(rows));
+});
+
+/* POST /api/regroupements-patrimoniaux — créer */
+router.post('/', (req, res, next) => {
+ try {
+ const data = Schema.parse(req.body);
+ const existing = db.prepare('SELECT id FROM regroupements_patrimoniaux WHERE nom = ?').get(data.nom);
+ if (existing) throw new HttpError(409, `Un regroupement "${data.nom}" existe déjà`);
+
+ let parent = null;
+ if (data.parent_id) {
+ parent = db.prepare('SELECT id, type FROM regroupements_patrimoniaux WHERE id = ?').get(data.parent_id);
+ if (!parent) throw new HttpError(400, 'Regroupement parent introuvable');
+ if (parent.parent_id) throw new HttpError(400, 'Un regroupement parent ne peut pas lui-même avoir un parent (hiérarchie à 2 niveaux maximum)');
+ }
+ // Le type d'un enfant suit toujours celui de son parent (pas de mélange actif/passif dans
+ // une même branche).
+ const type = parent ? parent.type : data.type;
+
+ const r = db.prepare(`
+ INSERT INTO regroupements_patrimoniaux (nom, parent_id, type, ordre_affichage, updated_at)
+ VALUES (?,?,?,?, datetime('now'))
+ `).run(data.nom, data.parent_id ?? null, type, data.ordre_affichage);
+
+ const row = db.prepare('SELECT * FROM regroupements_patrimoniaux WHERE id = ?').get(r.lastInsertRowid);
+ res.status(201).json(withCounts([row])[0]);
+ } catch (e) { next(e); }
+});
+
+/* PUT /api/regroupements-patrimoniaux/:id — modifier */
+router.put('/:id', (req, res, next) => {
+ try {
+ const row = db.prepare('SELECT * FROM regroupements_patrimoniaux WHERE id = ?').get(req.params.id);
+ if (!row) throw new HttpError(404, 'Regroupement introuvable');
+
+ const data = Schema.parse(req.body);
+ if (data.nom !== row.nom) {
+ const dup = db.prepare('SELECT id FROM regroupements_patrimoniaux WHERE nom = ? AND id != ?').get(data.nom, row.id);
+ if (dup) throw new HttpError(409, `Un regroupement "${data.nom}" existe déjà`);
+ }
+ if (data.parent_id === row.id) throw new HttpError(400, 'Un regroupement ne peut pas être son propre parent');
+
+ let parent = null;
+ if (data.parent_id) {
+ parent = db.prepare('SELECT id, parent_id, type FROM regroupements_patrimoniaux WHERE id = ?').get(data.parent_id);
+ if (!parent) throw new HttpError(400, 'Regroupement parent introuvable');
+ if (parent.parent_id) throw new HttpError(400, 'Un regroupement parent ne peut pas lui-même avoir un parent (hiérarchie à 2 niveaux maximum)');
+ }
+ // Empêche de donner un parent à un regroupement qui a lui-même déjà des enfants.
+ if (data.parent_id) {
+ const nbEnfants = db.prepare('SELECT COUNT(*) AS n FROM regroupements_patrimoniaux WHERE parent_id = ?').get(row.id).n;
+ if (nbEnfants > 0) throw new HttpError(400, 'Ce regroupement a déjà des sous-regroupements — il ne peut pas devenir lui-même un enfant.');
+ }
+ const type = parent ? parent.type : data.type;
+
+ db.prepare(`
+ UPDATE regroupements_patrimoniaux
+ SET nom=?, parent_id=?, type=?, ordre_affichage=?, updated_at=datetime('now')
+ WHERE id=?
+ `).run(data.nom, data.parent_id ?? null, type, data.ordre_affichage, row.id);
+
+ const updated = db.prepare('SELECT * FROM regroupements_patrimoniaux WHERE id = ?').get(row.id);
+ res.json(withCounts([updated])[0]);
+ } catch (e) { next(e); }
+});
+
+/* DELETE /api/regroupements-patrimoniaux/:id — supprimer si non utilisé et sans enfant */
+router.delete('/:id', (req, res, next) => {
+ try {
+ const row = db.prepare('SELECT id FROM regroupements_patrimoniaux WHERE id = ?').get(req.params.id);
+ if (!row) throw new HttpError(404, 'Regroupement introuvable');
+ const [withCount] = withCounts([row]);
+ if (withCount.nb_enfants > 0) throw new HttpError(409, `Ce regroupement a ${withCount.nb_enfants} sous-regroupement(s) — supprimez-les d'abord.`);
+ if (withCount.nb_enveloppes > 0) throw new HttpError(409, `Regroupement utilisé par ${withCount.nb_enveloppes} enveloppe(s) — retirez-le d'abord.`);
+ if (withCount.nb_emprunts > 0) throw new HttpError(409, `Regroupement utilisé par ${withCount.nb_emprunts} emprunt(s) — retirez-le d'abord.`);
+ db.prepare('DELETE FROM regroupements_patrimoniaux WHERE id = ?').run(row.id);
+ res.status(204).end();
+ } catch (e) { next(e); }
+});
+
+export default router;
diff --git a/backend/src/server.js b/backend/src/server.js
index 71e49fc..25c35dd 100644
--- a/backend/src/server.js
+++ b/backend/src/server.js
@@ -58,6 +58,7 @@ import enveloppesReferentielPublicRouter from './routes/enveloppesReferentielPub
import supportsReferentielRouter from './routes/supportsReferentiel.js';
import empruntsRouter from './routes/emprunts.js';
import refCategoriesEnveloppesRouter from './routes/ref-categories-enveloppes.js';
+import regroupementsPatrimoniauxRouter from './routes/regroupementsPatrimoniaux.js';
import refCategoriesRouter from './routes/ref-categories.js';
import refSecteursRouter from './routes/ref-secteurs.js';
import categoriesInvRouter from './routes/categories-inv.js';
@@ -189,6 +190,7 @@ app.use('/api/enveloppes-referentiel', requireAuth, requireAdmin, envelopp
app.use('/api/enveloppes-referentiel-public', requireAuth, enveloppesReferentielPublicRouter);
app.use('/api/supports-referentiel', requireAuth, requireAdmin, supportsReferentielRouter);
app.use('/api/ref-categories-enveloppes', requireAuth, requireAdmin, refCategoriesEnveloppesRouter);
+app.use('/api/regroupements-patrimoniaux', requireAuth, requireAdmin, regroupementsPatrimoniauxRouter);
app.use('/api/ref-categories', requireAuth, requireAdmin, refCategoriesRouter);
app.use('/api/ref-secteurs', requireAuth, requireAdmin, refSecteursRouter);
app.use('/api/categories-inv', requireAuth, categoriesInvRouter);
diff --git a/frontend/src/pages/AdminEnveloppes.jsx b/frontend/src/pages/AdminEnveloppes.jsx
index 1708e2e..16fee42 100644
--- a/frontend/src/pages/AdminEnveloppes.jsx
+++ b/frontend/src/pages/AdminEnveloppes.jsx
@@ -56,6 +56,7 @@ function EnveloppeModal({ row, onClose, onSaved }) {
const isNew = !row?.id;
const [categories, setCategories] = useState([]);
const [supports, setSupports] = useState([]);
+ const [regroupements, setRegroupements] = useState([]);
const [err, setErr] = useState(null);
const [saving, setSaving] = useState(false);
@@ -65,6 +66,7 @@ function EnveloppeModal({ row, onClose, onSaved }) {
plafond_eur: row?.plafond_eur != null ? String(row.plafond_eur) : '',
regime_fiscal: row?.regime_fiscal || '',
eligible_compte: !!row?.eligible_compte,
+ regroupement_patrimonial_id: row?.regroupement_patrimonial_id || '',
categories_ids: (row?.categories || []).map(c => c.id),
supports_ids: (row?.supports || []).map(s => s.id),
});
@@ -72,6 +74,7 @@ function EnveloppeModal({ row, onClose, onSaved }) {
useEffect(() => {
api.get('/ref-categories-enveloppes').then(setCategories).catch(() => {});
api.get('/supports-referentiel').then(setSupports).catch(() => {});
+ api.get('/regroupements-patrimoniaux').then(setRegroupements).catch(() => {});
}, []);
const set = (k, v) => setForm(f => ({ ...f, [k]: v }));
@@ -89,6 +92,7 @@ function EnveloppeModal({ row, onClose, onSaved }) {
plafond_eur: plafond,
regime_fiscal: form.regime_fiscal.trim() || null,
eligible_compte: !!form.eligible_compte,
+ regroupement_patrimonial_id: form.regroupement_patrimonial_id ? Number(form.regroupement_patrimonial_id) : null,
categories_ids: form.categories_ids || [],
supports_ids: form.supports_ids || [],
};
@@ -142,6 +146,26 @@ function EnveloppeModal({ row, onClose, onSaved }) {
auto-conservée, objets de collection…).
+
+
+
+
+ Famille utilisée pour une future vue patrimoine consolidée (Immobilier, Comptes
+ courants, Crowdlending…) — distincte des catégories ci-dessous, qui organisent le
+ catalogue admin.
+
+
@@ -299,12 +323,13 @@ function EnveloppesSection() {
Régime fiscal
Catégories
Supports
+
Regroupement
{filteredRows.length === 0 ? (
-
+
{rows.length === 0
? 'Aucune enveloppe dans le référentiel. Créez-en une pour commencer.'
: 'Aucun résultat pour ces critères.'}
@@ -339,6 +364,13 @@ function EnveloppesSection() {