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. +
+