Notion de Regroupements Patrimoine
This commit is contained in:
1 parent
9591ae400e
commit
efa5de79b8
6 files changed
+661
-22
No files matched your search
@@ -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;
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
|
||||
@@ -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…).
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label>Regroupement patrimonial</label>
|
||||
<select value={form.regroupement_patrimonial_id} onChange={e => set('regroupement_patrimonial_id', e.target.value)}
|
||||
style={{ width: '100%', padding: '7px 10px', borderRadius: 6, border: '1px solid var(--border)', background: 'var(--surface)', color: 'var(--text)', fontSize: 13 }}>
|
||||
<option value="">— Aucun —</option>
|
||||
{regroupements.filter(r => !r.parent_id).map(root => (
|
||||
<optgroup key={root.id} label={root.nom}>
|
||||
<option value={root.id}>{root.nom}</option>
|
||||
{regroupements.filter(r => r.parent_id === root.id).map(child => (
|
||||
<option key={child.id} value={child.id}>{root.nom} › {child.nom}</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
<p style={{ margin: '2px 0 0', fontSize: 11, color: 'var(--text-muted)' }}>
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label>Catégories</label>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||
@@ -299,12 +323,13 @@ function EnveloppesSection() {
|
||||
<th style={{ padding: '10px 8px', textAlign: 'left' }}>Régime fiscal</th>
|
||||
<th style={{ padding: '10px 8px', textAlign: 'left' }}>Catégories</th>
|
||||
<th style={{ padding: '10px 8px', textAlign: 'left' }}>Supports</th>
|
||||
<th style={{ padding: '10px 8px', textAlign: 'left' }}>Regroupement</th>
|
||||
<th style={{ padding: '10px 16px', width: 40 }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredRows.length === 0 ? (
|
||||
<tr><td colSpan={6} style={{ padding: 32, textAlign: 'center', color: 'var(--text-muted)' }}>
|
||||
<tr><td colSpan={7} style={{ padding: 32, textAlign: 'center', color: 'var(--text-muted)' }}>
|
||||
{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() {
|
||||
<td style={{ padding: '10px 8px', color: 'var(--text-muted)', fontSize: 12 }}>
|
||||
{(row.supports || []).length === 0 ? 'Cash (aucun)' : `${row.supports.length} support${row.supports.length > 1 ? 's' : ''}`}
|
||||
</td>
|
||||
<td style={{ padding: '10px 8px', color: 'var(--text-muted)', fontSize: 12 }}>
|
||||
{row.regroupement_patrimonial
|
||||
? (row.regroupement_patrimonial.parent_nom
|
||||
? `${row.regroupement_patrimonial.parent_nom} › ${row.regroupement_patrimonial.nom}`
|
||||
: row.regroupement_patrimonial.nom)
|
||||
: '—'}
|
||||
</td>
|
||||
<td style={{ padding: '10px 16px', textAlign: 'right' }}>
|
||||
<button
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '4px 8px', borderRadius: 4, fontSize: 18, color: 'var(--text-muted)', lineHeight: 1 }}
|
||||
@@ -754,10 +786,278 @@ function CategoriesEnveloppesSection() {
|
||||
);
|
||||
}
|
||||
|
||||
function IconGroup() {
|
||||
return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/></svg>;
|
||||
}
|
||||
|
||||
/* ── Modale de création/édition d'un regroupement patrimonial ─────
|
||||
Hiérarchie générique à 2 niveaux (parent_id) — un regroupement racine (Immobilier, Comptes
|
||||
courants…) ou un sous-regroupement rattaché à un parent (ex. Private Equity sous Comptes
|
||||
d'investissement). Le type (actif/passif) est libre pour un regroupement racine, et suit
|
||||
automatiquement celui de son parent pour un sous-regroupement — pas de mélange actif/passif
|
||||
dans une même branche. */
|
||||
function RegroupementModal({ row, allRegroupements, onClose, onSaved }) {
|
||||
const isNew = !row?.id;
|
||||
const [err, setErr] = useState(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const hasChildren = (row?.nb_enfants || 0) > 0;
|
||||
const parentOptions = allRegroupements.filter(r => !r.parent_id && r.id !== row?.id);
|
||||
|
||||
const [form, setForm] = useState({
|
||||
nom: row?.nom || '',
|
||||
parent_id: row?.parent_id || '',
|
||||
type: row?.type || 'actif',
|
||||
ordre_affichage: row?.ordre_affichage != null ? String(row.ordre_affichage) : '0',
|
||||
});
|
||||
|
||||
const set = (k, v) => setForm(f => ({ ...f, [k]: v }));
|
||||
|
||||
const selectedParent = form.parent_id ? allRegroupements.find(r => String(r.id) === String(form.parent_id)) : null;
|
||||
const effectiveType = selectedParent ? selectedParent.type : form.type;
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!form.nom.trim()) { setErr('Le nom est requis.'); return; }
|
||||
setSaving(true); setErr(null);
|
||||
try {
|
||||
const payload = {
|
||||
nom: form.nom.trim(),
|
||||
parent_id: form.parent_id ? Number(form.parent_id) : null,
|
||||
type: effectiveType,
|
||||
ordre_affichage: Number(form.ordre_affichage) || 0,
|
||||
};
|
||||
if (isNew) await api.post('/regroupements-patrimoniaux', payload);
|
||||
else await api.put(`/regroupements-patrimoniaux/${row.id}`, payload);
|
||||
onSaved(); onClose();
|
||||
} catch (ex) { setErr(ex.message); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
title={isNew ? 'Ajouter un regroupement' : `Modifier — ${row.nom}`}
|
||||
onClose={onClose}
|
||||
width={480}
|
||||
footer={
|
||||
<>
|
||||
<button type="button" onClick={onClose} disabled={saving}>Annuler</button>
|
||||
<button type="submit" form="regroupement-form" className="primary" disabled={saving}>
|
||||
{saving ? '…' : (isNew ? 'Créer' : 'Enregistrer')}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id="regroupement-form" onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
{err && <div className="error">{err}</div>}
|
||||
<div>
|
||||
<label>Nom *</label>
|
||||
<input value={form.nom} onChange={e => set('nom', e.target.value)} placeholder="Ex : Immobilier, Crowdlending, Private Equity…" />
|
||||
</div>
|
||||
<div>
|
||||
<label>Regroupement parent</label>
|
||||
<select value={form.parent_id} onChange={e => set('parent_id', e.target.value)} disabled={hasChildren}
|
||||
style={{ width: '100%', padding: '7px 10px', borderRadius: 6, border: '1px solid var(--border)', background: 'var(--surface)', color: 'var(--text)', fontSize: 13 }}>
|
||||
<option value="">— Aucun (regroupement racine) —</option>
|
||||
{parentOptions.map(r => <option key={r.id} value={r.id}>{r.nom}</option>)}
|
||||
</select>
|
||||
{hasChildren ? (
|
||||
<p style={{ margin: '2px 0 0', fontSize: 11, color: 'var(--text-muted)' }}>
|
||||
Ce regroupement a déjà des sous-regroupements — il ne peut pas devenir lui-même un enfant.
|
||||
</p>
|
||||
) : (
|
||||
<p style={{ margin: '2px 0 0', fontSize: 11, color: 'var(--text-muted)' }}>
|
||||
Laisser vide pour un regroupement racine (ex. Immobilier) — choisir un parent pour
|
||||
une sous-catégorie (ex. Private Equity sous Comptes d'investissement).
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label>Type</label>
|
||||
{selectedParent ? (
|
||||
<p style={{ margin: '4px 0 0', fontSize: 13 }}>
|
||||
{effectiveType === 'passif' ? 'Passif' : 'Actif'} <span style={{ color: 'var(--text-muted)', fontSize: 11 }}>(hérité du parent « {selectedParent.nom} »)</span>
|
||||
</p>
|
||||
) : (
|
||||
<select value={form.type} onChange={e => set('type', e.target.value)}
|
||||
style={{ width: '100%', padding: '7px 10px', borderRadius: 6, border: '1px solid var(--border)', background: 'var(--surface)', color: 'var(--text)', fontSize: 13 }}>
|
||||
<option value="actif">Actif</option>
|
||||
<option value="passif">Passif</option>
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label>Ordre d'affichage</label>
|
||||
<input type="number" value={form.ordre_affichage} onChange={e => set('ordre_affichage', e.target.value)} style={{ width: 120 }} />
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Section "Regroupements patrimoniaux" ──────────────────────────
|
||||
Vue consolidée par grandes familles d'actifs (chantier du 28/09/26 (suite), demande Olivier —
|
||||
cf. claude/plan_regroupements_patrimoniaux.md). Orthogonal aux catégories : une même catégorie
|
||||
du catalogue (ex. "Financement participatif / private equity") peut se scinder entre plusieurs
|
||||
regroupements ici (Crowdlending vs Private Equity). */
|
||||
function RegroupementsSection() {
|
||||
const [rows, setRows] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [err, setErr] = useState(null);
|
||||
const [editing, setEditing] = useState(null);
|
||||
const [openMenu, setOpenMenu] = useState(null);
|
||||
const [confirm, setConfirm] = useState(null);
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true);
|
||||
api.get('/regroupements-patrimoniaux')
|
||||
.then(data => { setRows(data); setErr(null); })
|
||||
.catch(e => setErr(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!openMenu) return;
|
||||
const close = () => setOpenMenu(null);
|
||||
window.addEventListener('scroll', close, true);
|
||||
return () => window.removeEventListener('scroll', close, true);
|
||||
}, [openMenu]);
|
||||
|
||||
// Racines triées par ordre_affichage, chaque enfant juste après son parent.
|
||||
const ordered = useMemo(() => {
|
||||
const roots = rows.filter(r => !r.parent_id).sort((a, b) => a.ordre_affichage - b.ordre_affichage);
|
||||
const out = [];
|
||||
for (const root of roots) {
|
||||
out.push(root);
|
||||
rows.filter(r => r.parent_id === root.id).sort((a, b) => a.ordre_affichage - b.ordre_affichage)
|
||||
.forEach(child => out.push(child));
|
||||
}
|
||||
return out;
|
||||
}, [rows]);
|
||||
|
||||
const handleDelete = (row) => {
|
||||
setConfirm({
|
||||
title: 'Supprimer ce regroupement ?',
|
||||
message: `Supprimer "${row.nom}" ? Cette action est définitive.`,
|
||||
confirmLabel: 'Supprimer',
|
||||
onConfirm: async () => {
|
||||
setConfirm(null);
|
||||
try { await api.del(`/regroupements-patrimoniaux/${row.id}`); load(); }
|
||||
catch (ex) { setErr(ex.message); }
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const openContextMenu = (e, row) => {
|
||||
e.stopPropagation();
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
setOpenMenu({ row, x: rect.right - 160, y: rect.bottom + 4 });
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<div>
|
||||
<h2 style={{ margin: 0, fontSize: 18 }}>Regroupements patrimoniaux</h2>
|
||||
<p style={{ margin: '4px 0 0', color: 'var(--text-muted)', fontSize: 13 }}>
|
||||
Grandes familles pour une vue patrimoine consolidée (Immobilier, Comptes courants,
|
||||
Crowdlending…), et le passif (Emprunts) — distinct des catégories du catalogue.
|
||||
</p>
|
||||
</div>
|
||||
<button className="primary" onClick={() => setEditing('new')}>+ Ajouter</button>
|
||||
</div>
|
||||
|
||||
{err && <div className="error" style={{ marginBottom: 12 }}>{err}</div>}
|
||||
|
||||
{loading ? <p style={{ color: 'var(--text-muted)' }}>Chargement…</p> : (
|
||||
<div className="card" style={{ padding: 0, overflow: 'hidden' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid var(--border)', background: 'var(--surface-2)' }}>
|
||||
<th style={{ padding: '10px 16px', textAlign: 'left' }}>Nom</th>
|
||||
<th style={{ padding: '10px 8px', textAlign: 'left' }}>Type</th>
|
||||
<th style={{ padding: '10px 8px', textAlign: 'center' }}>Enveloppes</th>
|
||||
<th style={{ padding: '10px 8px', textAlign: 'center' }}>Emprunts</th>
|
||||
<th style={{ padding: '10px 16px', width: 40 }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ordered.length === 0 ? (
|
||||
<tr><td colSpan={5} style={{ padding: 32, textAlign: 'center', color: 'var(--text-muted)' }}>Aucun regroupement.</td></tr>
|
||||
) : ordered.map((row, i) => (
|
||||
<tr key={row.id} style={{ borderBottom: i < ordered.length - 1 ? '1px solid var(--border)' : 'none' }}>
|
||||
<td style={{ padding: '10px 16px', fontWeight: row.parent_id ? 400 : 600 }}>
|
||||
{row.parent_id ? <span style={{ color: 'var(--text-muted)', marginRight: 4 }}>›</span> : null}
|
||||
{row.nom}
|
||||
</td>
|
||||
<td style={{ padding: '10px 8px' }}>
|
||||
<span style={{ fontSize: 11, fontWeight: 600, padding: '1px 7px', borderRadius: 10,
|
||||
background: row.type === 'passif' ? 'rgba(239,68,68,.12)' : 'rgba(34,197,94,.12)',
|
||||
color: row.type === 'passif' ? '#ef4444' : '#22c55e' }}>
|
||||
{row.type === 'passif' ? 'Passif' : 'Actif'}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: '10px 8px', textAlign: 'center', color: 'var(--text-muted)' }}>{row.nb_enveloppes}</td>
|
||||
<td style={{ padding: '10px 8px', textAlign: 'center', color: 'var(--text-muted)' }}>{row.nb_emprunts}</td>
|
||||
<td style={{ padding: '10px 16px', textAlign: 'right' }}>
|
||||
<button
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '4px 8px', borderRadius: 4, fontSize: 18, color: 'var(--text-muted)', lineHeight: 1 }}
|
||||
onClick={e => openContextMenu(e, row)}
|
||||
>⋮</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{openMenu && (
|
||||
<>
|
||||
<div style={{ position: 'fixed', inset: 0, zIndex: 299 }} onClick={() => setOpenMenu(null)} />
|
||||
<div style={{ position: 'fixed', left: openMenu.x, top: openMenu.y, 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 }}>
|
||||
<button
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '8px 14px', background: 'none', border: 'none', cursor: 'pointer', fontSize: 'var(--fs-sm)', color: 'var(--text)', textAlign: 'left' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--surface-2)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'none'}
|
||||
onClick={() => { const r = openMenu.row; setOpenMenu(null); setEditing(r); }}>
|
||||
<IconEdit /> Modifier
|
||||
</button>
|
||||
<button
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '8px 14px', background: 'none', border: 'none', cursor: 'pointer', fontSize: 'var(--fs-sm)', color: 'var(--danger)', textAlign: 'left' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--surface-2)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'none'}
|
||||
onClick={() => { const r = openMenu.row; setOpenMenu(null); handleDelete(r); }}>
|
||||
<IconTrash /> Supprimer
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{editing && (
|
||||
<RegroupementModal
|
||||
row={editing === 'new' ? null : editing}
|
||||
allRegroupements={rows}
|
||||
onClose={() => setEditing(null)}
|
||||
onSaved={load}
|
||||
/>
|
||||
)}
|
||||
<ConfirmModal open={!!confirm} title={confirm?.title} message={confirm?.message} confirmLabel={confirm?.confirmLabel}
|
||||
onConfirm={confirm?.onConfirm} onCancel={() => setConfirm(null)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const NAV = [
|
||||
{ id: 'enveloppes', label: 'Enveloppes', icon: <IconWallet /> },
|
||||
{ id: 'supports', label: 'Supports', icon: <IconLayers /> },
|
||||
{ id: 'categories', label: 'Catégories', icon: <IconTag /> },
|
||||
{ id: 'enveloppes', label: 'Enveloppes', icon: <IconWallet /> },
|
||||
{ id: 'supports', label: 'Supports', icon: <IconLayers /> },
|
||||
{ id: 'categories', label: 'Catégories', icon: <IconTag /> },
|
||||
{ id: 'regroupements', label: 'Regroupements', icon: <IconGroup /> },
|
||||
];
|
||||
|
||||
/* ── Page principale ──────────────────────────────────────────── */
|
||||
@@ -794,9 +1094,10 @@ export default function AdminEnveloppes() {
|
||||
</div>
|
||||
</aside>
|
||||
<div className="account-content">
|
||||
{section === 'enveloppes' && <EnveloppesSection />}
|
||||
{section === 'supports' && <SupportsSection />}
|
||||
{section === 'categories' && <CategoriesEnveloppesSection />}
|
||||
{section === 'enveloppes' && <EnveloppesSection />}
|
||||
{section === 'supports' && <SupportsSection />}
|
||||
{section === 'categories' && <CategoriesEnveloppesSection />}
|
||||
{section === 'regroupements' && <RegroupementsSection />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user