Maj
No files matched your search
@@ -4086,4 +4086,122 @@ db.prepare(`
|
|||||||
// stade, la suppression est donc sans risque de perte de données utilisateur.
|
// stade, la suppression est donc sans risque de perte de données utilisateur.
|
||||||
db.prepare("DELETE FROM supports_referentiel WHERE nom = 'Gestion pilotée / mandat'").run();
|
db.prepare("DELETE FROM supports_referentiel WHERE nom = 'Gestion pilotée / mandat'").run();
|
||||||
|
|
||||||
|
|
||||||
|
// ── Migration : eligible_compte sur enveloppes_referentiel (Phase 3a, 28/09/26, demande
|
||||||
|
// Olivier — chantier "rattacher Mes comptes courants au référentiel") ────────────────────
|
||||||
|
// Distingue les enveloppes qui ont le profil d'un vrai "compte" (banque/courtier/assureur —
|
||||||
|
// Livret A, PEA, Assurance-vie, CTO, Compte crypto exchange...) de celles qui n'en ont pas
|
||||||
|
// (immobilier physique, objets de collection, wallets crypto auto-conservés, placements via
|
||||||
|
// plateforme déjà couverts par le mécanisme plateformes_referentiel...). Sert à filtrer le
|
||||||
|
// sélecteur "Enveloppe" de Paramètres > Mes comptes (cf. comptes.enveloppe_id ci-dessous) —
|
||||||
|
// cadrage validé par Olivier via AskUserQuestion (28/09/26) : sous-ensemble "compte financier",
|
||||||
|
// pas tout le référentiel. Colonne booléenne plutôt que catégorie existante réutilisée : la
|
||||||
|
// classification ne suit aucune des 15 catégories actuelles (ex. "Actifs numériques" contient
|
||||||
|
// à la fois Compte crypto exchange, éligible, et les wallets auto-conservés, non éligibles).
|
||||||
|
{
|
||||||
|
const cols = db.prepare('PRAGMA table_info(enveloppes_referentiel)').all().map(c => c.name);
|
||||||
|
if (!cols.includes('eligible_compte')) {
|
||||||
|
db.exec("ALTER TABLE enveloppes_referentiel ADD COLUMN eligible_compte INTEGER NOT NULL DEFAULT 0");
|
||||||
|
// Backfill exécuté une seule fois (dans cette même garde de colonne) : classification
|
||||||
|
// initiale par nom, jamais retouchée ensuite même si l'admin édite ce flag à la main.
|
||||||
|
const ELIGIBLE_COMPTE_NOMS = [
|
||||||
|
// Comptes courants
|
||||||
|
'Compte courant non rémunéré', 'Compte courant rémunéré', 'Compte rémunéré nouvelle génération (fintech)',
|
||||||
|
// Épargne réglementée
|
||||||
|
'Livret A', 'LDDS', 'LEP', 'Livret Jeune', 'CEL', 'PEL',
|
||||||
|
// Épargne bancaire non réglementée
|
||||||
|
'Livret bancaire non réglementé', 'Compte à terme (CAT)',
|
||||||
|
// Assurance-vie et assimilés
|
||||||
|
'Assurance-vie monosupport (fonds euros)', 'Assurance-vie multisupport',
|
||||||
|
'Contrat de capitalisation', 'Assurance-vie luxembourgeoise',
|
||||||
|
// Épargne retraite
|
||||||
|
'PER individuel', 'PER collectif / obligatoire (PERCOL, article 83)',
|
||||||
|
// Épargne salariale
|
||||||
|
'PEE',
|
||||||
|
// Enveloppes titres / marchés financiers
|
||||||
|
'PEA', 'PEA-PME', 'Compte-titres ordinaire (CTO)',
|
||||||
|
// Actifs numériques — seul le compte sur exchange a une institution (l'exchange
|
||||||
|
// lui-même) ; les wallets auto-conservés/conservés par un tiers restent hors périmètre.
|
||||||
|
'Compte crypto (exchange)',
|
||||||
|
// Épargne solidaire
|
||||||
|
'Livret solidaire',
|
||||||
|
];
|
||||||
|
const setEligible = db.prepare('UPDATE enveloppes_referentiel SET eligible_compte = 1 WHERE nom = ?');
|
||||||
|
db.transaction(() => { for (const nom of ELIGIBLE_COMPTE_NOMS) setEligible.run(nom); })();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Migration : enveloppe_id sur comptes (Phase 3a, 28/09/26, demande Olivier) ────────────
|
||||||
|
// Rattache un compte au référentiel enveloppes_referentiel — nullable, non-destructif, sur le
|
||||||
|
// même principe que comptes.institution_id (Phase 2, cf. plan de déploiement Institutions
|
||||||
|
// financières) : le champ `type` existant (compte_courant/pea_pme) reste en base tel quel et
|
||||||
|
// continue de piloter la logique de remboursement/frais déjà en place (de nombreux écrans
|
||||||
|
// investissement/remboursement font comptes.find(c => c.type === 'compte_courant') pour
|
||||||
|
// proposer un compte de règlement par défaut) — enveloppe_id est un enrichissement PUREMENT
|
||||||
|
// descriptif, ajouté à côté du champ type, jamais en remplacement, pour ne rien casser de cette
|
||||||
|
// logique existante. Restreint aux enveloppes "compte financier" (eligible_compte = 1) côté
|
||||||
|
// sélecteur frontend uniquement — pas de contrainte CHECK en base, pour rester simple.
|
||||||
|
{
|
||||||
|
const cols = db.prepare('PRAGMA table_info(comptes)').all().map(c => c.name);
|
||||||
|
if (!cols.includes('enveloppe_id')) {
|
||||||
|
db.exec('ALTER TABLE comptes ADD COLUMN enveloppe_id INTEGER REFERENCES enveloppes_referentiel(id) ON DELETE SET NULL');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Extension du 28/09/26 (Phase 3a) — catégorie "Gestion de trésorerie" + Spiko / Regular
|
||||||
|
// Finance ──────────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Identifiés pendant le chantier "Référentiel Enveloppes & Supports d'épargne" (cf. "Extension
|
||||||
|
// du 29/09/26 (suite) — support Fonds monétaires" dans le plan de déploiement dédié) : Spiko et
|
||||||
|
// Regular Finance ne sont pas des enveloppes mais des plateformes/institutions donnant accès à
|
||||||
|
// un fonds monétaire (OPCVM monétaire, y compris tokenisé sur blockchain chez Spiko) — elles ont
|
||||||
|
// vocation à rejoindre institutions_referentiel, pas enveloppes_referentiel. Toujours exécuté,
|
||||||
|
// INSERT OR IGNORE sur des clés UNIQUE(nom) — s'ajoute sans jamais dupliquer ni écraser le
|
||||||
|
// référentiel déjà seedé (Phase 1), même principe que les extensions "EXTRA" du chantier
|
||||||
|
// enveloppes.
|
||||||
|
{
|
||||||
|
db.prepare('INSERT OR IGNORE INTO categories_institutions (nom) VALUES (?)').run('Gestion de trésorerie');
|
||||||
|
const insInst = db.prepare('INSERT OR IGNORE INTO institutions_referentiel (nom) VALUES (?)');
|
||||||
|
const getInst = db.prepare('SELECT id FROM institutions_referentiel WHERE nom = ?');
|
||||||
|
const getCat = db.prepare('SELECT id FROM categories_institutions WHERE nom = ?');
|
||||||
|
const linkCat = db.prepare('INSERT OR IGNORE INTO referentiel_categories_institutions (institution_id, categorie_id) VALUES (?, ?)');
|
||||||
|
db.transaction(() => {
|
||||||
|
for (const nom of ['Spiko', 'Regular Finance']) {
|
||||||
|
insInst.run(nom);
|
||||||
|
const inst = getInst.get(nom);
|
||||||
|
const cat = getCat.get('Gestion de trésorerie');
|
||||||
|
if (inst && cat) linkCat.run(inst.id, cat.id);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Chantier "Passif — Emprunts" (Phase 3c, 28/09/26, demande Olivier) ───────────────────────
|
||||||
|
// Objet séparé de enveloppes_referentiel : un emprunt est structurellement différent d'un actif
|
||||||
|
// (le capital restant dû DIMINUE avec le temps, porte un taux et un échéancier fixés à la
|
||||||
|
// souscription) — cf. "Réflexion prospective" du plan de déploiement Enveloppes & Supports (doc
|
||||||
|
// projet), point 3 ("un passif, pas un actif"). Table dédiée plutôt qu'un champ sur comptes,
|
||||||
|
// pour la même raison qu'un compte n'est jamais une "enveloppe" : structure et cycle de vie
|
||||||
|
// différents (un compte se crédite/débite librement, un emprunt s'amortit). Rattachement
|
||||||
|
// institution optionnel (établissement prêteur) via institutions_referentiel, sur le même
|
||||||
|
// principe que comptes.institution_id — nullable, non contraignant.
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS emprunts (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
investisseur_id INTEGER REFERENCES investisseurs(id) ON DELETE SET NULL,
|
||||||
|
institution_id INTEGER REFERENCES institutions_referentiel(id) ON DELETE SET NULL,
|
||||||
|
nom TEXT NOT NULL,
|
||||||
|
montant_emprunte REAL NOT NULL,
|
||||||
|
capital_restant_du REAL NOT NULL,
|
||||||
|
taux_nominal REAL,
|
||||||
|
date_debut TEXT,
|
||||||
|
duree_mois INTEGER,
|
||||||
|
mensualite REAL,
|
||||||
|
notes TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
db.exec('CREATE INDEX IF NOT EXISTS idx_emprunts_user ON emprunts(user_id)');
|
||||||
|
db.exec('CREATE INDEX IF NOT EXISTS idx_emprunts_inv ON emprunts(investisseur_id)');
|
||||||
|
|
||||||
export default db;
|
export default db;
|
||||||
@@ -11,19 +11,26 @@ const Schema = z.object({
|
|||||||
investisseur_id: z.number().int().positive().nullable().optional(),
|
investisseur_id: z.number().int().positive().nullable().optional(),
|
||||||
banque: z.string().nullable().optional(),
|
banque: z.string().nullable().optional(),
|
||||||
institution_id: z.number().int().positive().nullable().optional(),
|
institution_id: z.number().int().positive().nullable().optional(),
|
||||||
|
// Phase 3a (28/09/26) — rattachement au référentiel enveloppes_referentiel, purement
|
||||||
|
// descriptif et non-destructif (cf. commentaire de migration dans db/index.js) : n'a aucune
|
||||||
|
// incidence sur `type`, qui reste le champ utilisé par la logique de remboursement/frais
|
||||||
|
// existante. Non contraint à eligible_compte=1 côté Zod — le filtrage se fait côté frontend.
|
||||||
|
enveloppe_id: z.number().int().positive().nullable().optional(),
|
||||||
exoneration_fiscale: z.enum(['aucune', 'pfnl_5ans']).default('aucune'),
|
exoneration_fiscale: z.enum(['aucune', 'pfnl_5ans']).default('aucune'),
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/', (req, res) => {
|
router.get('/', (req, res) => {
|
||||||
const rows = db.prepare(`
|
const rows = db.prepare(`
|
||||||
SELECT c.id, c.nom, c.type, c.banque, c.institution_id, c.exoneration_fiscale, c.investisseur_id, c.created_at,
|
SELECT c.id, c.nom, c.type, c.banque, c.institution_id, c.enveloppe_id, c.exoneration_fiscale, c.investisseur_id, c.created_at,
|
||||||
inv.nom AS investisseur_nom, inv.prenom AS investisseur_prenom,
|
inv.nom AS investisseur_nom, inv.prenom AS investisseur_prenom,
|
||||||
inv.type AS investisseur_type, inv.type_fiscal AS investisseur_type_fiscal,
|
inv.type AS investisseur_type, inv.type_fiscal AS investisseur_type_fiscal,
|
||||||
ir.nom AS institution_nom, ir.domiciliation AS institution_domiciliation,
|
ir.nom AS institution_nom, ir.domiciliation AS institution_domiciliation,
|
||||||
ir.logo_filename AS institution_logo_filename, ir.icone_filename AS institution_icone_filename
|
ir.logo_filename AS institution_logo_filename, ir.icone_filename AS institution_icone_filename,
|
||||||
|
er.nom AS enveloppe_nom
|
||||||
FROM comptes c
|
FROM comptes c
|
||||||
LEFT JOIN investisseurs inv ON inv.id = c.investisseur_id
|
LEFT JOIN investisseurs inv ON inv.id = c.investisseur_id
|
||||||
LEFT JOIN institutions_referentiel ir ON ir.id = c.institution_id
|
LEFT JOIN institutions_referentiel ir ON ir.id = c.institution_id
|
||||||
|
LEFT JOIN enveloppes_referentiel er ON er.id = c.enveloppe_id
|
||||||
WHERE c.user_id = ?
|
WHERE c.user_id = ?
|
||||||
ORDER BY c.nom
|
ORDER BY c.nom
|
||||||
`).all(req.user.id);
|
`).all(req.user.id);
|
||||||
@@ -34,8 +41,8 @@ router.post('/', (req, res, next) => {
|
|||||||
try {
|
try {
|
||||||
const body = Schema.parse(req.body);
|
const body = Schema.parse(req.body);
|
||||||
const r = db.prepare(
|
const r = db.prepare(
|
||||||
'INSERT INTO comptes (user_id, nom, type, banque, institution_id, investisseur_id, exoneration_fiscale) VALUES (?,?,?,?,?,?,?)'
|
'INSERT INTO comptes (user_id, nom, type, banque, institution_id, enveloppe_id, investisseur_id, exoneration_fiscale) VALUES (?,?,?,?,?,?,?,?)'
|
||||||
).run(req.user.id, body.nom, body.type, body.banque || null, body.institution_id ?? null, body.investisseur_id ?? null, body.exoneration_fiscale ?? 'aucune');
|
).run(req.user.id, body.nom, body.type, body.banque || null, body.institution_id ?? null, body.enveloppe_id ?? null, body.investisseur_id ?? null, body.exoneration_fiscale ?? 'aucune');
|
||||||
res.status(201).json({ id: r.lastInsertRowid, ...body });
|
res.status(201).json({ id: r.lastInsertRowid, ...body });
|
||||||
} catch (e) { next(e); }
|
} catch (e) { next(e); }
|
||||||
});
|
});
|
||||||
@@ -44,8 +51,8 @@ router.put('/:id', (req, res, next) => {
|
|||||||
try {
|
try {
|
||||||
const body = Schema.parse(req.body);
|
const body = Schema.parse(req.body);
|
||||||
const r = db.prepare(
|
const r = db.prepare(
|
||||||
`UPDATE comptes SET nom=?, type=?, banque=?, institution_id=?, investisseur_id=?, exoneration_fiscale=?, updated_at=datetime('now') WHERE id=? AND user_id=?`
|
`UPDATE comptes SET nom=?, type=?, banque=?, institution_id=?, enveloppe_id=?, investisseur_id=?, exoneration_fiscale=?, updated_at=datetime('now') WHERE id=? AND user_id=?`
|
||||||
).run(body.nom, body.type, body.banque || null, body.institution_id ?? null, body.investisseur_id ?? null, body.exoneration_fiscale ?? 'aucune', req.params.id, req.user.id);
|
).run(body.nom, body.type, body.banque || null, body.institution_id ?? null, body.enveloppe_id ?? null, body.investisseur_id ?? null, body.exoneration_fiscale ?? 'aucune', req.params.id, req.user.id);
|
||||||
if (r.changes === 0) throw new HttpError(404, 'Not found');
|
if (r.changes === 0) throw new HttpError(404, 'Not found');
|
||||||
res.json({ id: Number(req.params.id), ...body });
|
res.json({ id: Number(req.params.id), ...body });
|
||||||
} catch (e) { next(e); }
|
} catch (e) { next(e); }
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import db from '../db/index.js';
|
||||||
|
import { HttpError } from '../middleware/errorHandler.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* routes/emprunts.js — Passif patrimonial (Phase 3c, 28/09/26, demande Olivier). Objet séparé
|
||||||
|
* de comptes/enveloppes_referentiel : un emprunt est structurellement différent d'un actif (le
|
||||||
|
* capital restant dû diminue avec le temps, porte un taux et un échéancier fixés à la
|
||||||
|
* souscription) — cf. "Réflexion prospective" du plan de déploiement Enveloppes & Supports (doc
|
||||||
|
* projet), point 3. Mêmes conventions que comptes.js : scope par user_id, détenteur
|
||||||
|
* (investisseur_id) et établissement prêteur (institution_id) optionnels et nullables.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
const Schema = z.object({
|
||||||
|
nom: z.string().min(1),
|
||||||
|
investisseur_id: z.number().int().positive().nullable().optional(),
|
||||||
|
institution_id: z.number().int().positive().nullable().optional(),
|
||||||
|
montant_emprunte: z.number().positive(),
|
||||||
|
capital_restant_du: z.number().nonnegative(),
|
||||||
|
taux_nominal: z.number().nullable().optional(),
|
||||||
|
date_debut: z.string().nullable().optional(),
|
||||||
|
duree_mois: z.number().int().positive().nullable().optional(),
|
||||||
|
mensualite: z.number().nullable().optional(),
|
||||||
|
notes: z.string().nullable().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
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,
|
||||||
|
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,
|
||||||
|
ir.logo_filename AS institution_logo_filename, ir.icone_filename AS institution_icone_filename
|
||||||
|
FROM emprunts e
|
||||||
|
LEFT JOIN investisseurs inv ON inv.id = e.investisseur_id
|
||||||
|
LEFT JOIN institutions_referentiel ir ON ir.id = e.institution_id
|
||||||
|
WHERE e.user_id = ?
|
||||||
|
ORDER BY e.nom
|
||||||
|
`).all(req.user.id);
|
||||||
|
res.json(rows);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/', (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const body = Schema.parse(req.body);
|
||||||
|
if (body.capital_restant_du > body.montant_emprunte) {
|
||||||
|
throw new HttpError(400, 'Le capital restant dû ne peut pas dépasser le montant emprunté');
|
||||||
|
}
|
||||||
|
const r = db.prepare(`
|
||||||
|
INSERT INTO emprunts (user_id, nom, investisseur_id, institution_id, montant_emprunte, capital_restant_du, taux_nominal, date_debut, duree_mois, mensualite, notes)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?,?,?)
|
||||||
|
`).run(
|
||||||
|
req.user.id, body.nom, body.investisseur_id ?? null, body.institution_id ?? null,
|
||||||
|
body.montant_emprunte, body.capital_restant_du, body.taux_nominal ?? null,
|
||||||
|
body.date_debut || null, body.duree_mois ?? null, body.mensualite ?? null, body.notes || null,
|
||||||
|
);
|
||||||
|
res.status(201).json({ id: r.lastInsertRowid, ...body });
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/:id', (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const body = Schema.parse(req.body);
|
||||||
|
if (body.capital_restant_du > body.montant_emprunte) {
|
||||||
|
throw new HttpError(400, 'Le capital restant dû ne peut pas dépasser le montant emprunté');
|
||||||
|
}
|
||||||
|
const r = db.prepare(`
|
||||||
|
UPDATE emprunts
|
||||||
|
SET nom=?, investisseur_id=?, institution_id=?, montant_emprunte=?, capital_restant_du=?,
|
||||||
|
taux_nominal=?, date_debut=?, duree_mois=?, mensualite=?, notes=?, updated_at=datetime('now')
|
||||||
|
WHERE id=? AND user_id=?
|
||||||
|
`).run(
|
||||||
|
body.nom, body.investisseur_id ?? null, body.institution_id ?? null,
|
||||||
|
body.montant_emprunte, body.capital_restant_du, body.taux_nominal ?? null,
|
||||||
|
body.date_debut || null, body.duree_mois ?? null, body.mensualite ?? null, body.notes || null,
|
||||||
|
req.params.id, req.user.id,
|
||||||
|
);
|
||||||
|
if (r.changes === 0) throw new HttpError(404, 'Not found');
|
||||||
|
res.json({ id: Number(req.params.id), ...body });
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/:id', (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const r = db.prepare('DELETE FROM emprunts WHERE id=? AND user_id=?')
|
||||||
|
.run(req.params.id, req.user.id);
|
||||||
|
if (r.changes === 0) throw new HttpError(404, 'Not found');
|
||||||
|
res.status(204).end();
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -29,6 +29,7 @@ const Schema = z.object({
|
|||||||
description: z.string().nullable().optional(),
|
description: z.string().nullable().optional(),
|
||||||
plafond_eur: z.number().positive().nullable().optional(),
|
plafond_eur: z.number().positive().nullable().optional(),
|
||||||
regime_fiscal: z.string().nullable().optional(),
|
regime_fiscal: z.string().nullable().optional(),
|
||||||
|
eligible_compte: z.boolean().optional().default(false),
|
||||||
categories_ids: z.array(z.number().int()).optional().default([]),
|
categories_ids: z.array(z.number().int()).optional().default([]),
|
||||||
supports_ids: z.array(z.number().int()).optional().default([]),
|
supports_ids: z.array(z.number().int()).optional().default([]),
|
||||||
});
|
});
|
||||||
@@ -114,9 +115,9 @@ router.post('/', (req, res, next) => {
|
|||||||
if (existing) throw new HttpError(409, `Une enveloppe "${data.nom}" existe déjà`);
|
if (existing) throw new HttpError(409, `Une enveloppe "${data.nom}" existe déjà`);
|
||||||
|
|
||||||
const r = db.prepare(`
|
const r = db.prepare(`
|
||||||
INSERT INTO enveloppes_referentiel (nom, description, plafond_eur, regime_fiscal, updated_at)
|
INSERT INTO enveloppes_referentiel (nom, description, plafond_eur, regime_fiscal, eligible_compte, updated_at)
|
||||||
VALUES (?,?,?,?, datetime('now'))
|
VALUES (?,?,?,?,?, datetime('now'))
|
||||||
`).run(data.nom, data.description || null, data.plafond_eur ?? null, data.regime_fiscal || null);
|
`).run(data.nom, data.description || null, data.plafond_eur ?? null, data.regime_fiscal || null, data.eligible_compte ? 1 : 0);
|
||||||
|
|
||||||
const enveloppeId = r.lastInsertRowid;
|
const enveloppeId = r.lastInsertRowid;
|
||||||
db.transaction(() => {
|
db.transaction(() => {
|
||||||
@@ -144,9 +145,9 @@ router.put('/:id', (req, res, next) => {
|
|||||||
|
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
UPDATE enveloppes_referentiel
|
UPDATE enveloppes_referentiel
|
||||||
SET nom=?, description=?, plafond_eur=?, regime_fiscal=?, updated_at=datetime('now')
|
SET nom=?, description=?, plafond_eur=?, regime_fiscal=?, eligible_compte=?, updated_at=datetime('now')
|
||||||
WHERE id=?
|
WHERE id=?
|
||||||
`).run(data.nom, data.description || null, data.plafond_eur ?? null, data.regime_fiscal || null, row.id);
|
`).run(data.nom, data.description || null, data.plafond_eur ?? null, data.regime_fiscal || null, data.eligible_compte ? 1 : 0, row.id);
|
||||||
|
|
||||||
db.transaction(() => {
|
db.transaction(() => {
|
||||||
saveCategories(row.id, data.categories_ids);
|
saveCategories(row.id, data.categories_ids);
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import db from '../db/index.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* routes/enveloppesReferentielPublic.js — lecture seule du référentiel "Enveloppes & Supports
|
||||||
|
* d'épargne" pour tous les utilisateurs authentifiés (pas requireAdmin), sur le modèle de
|
||||||
|
* institutionsReferentielPublic.js. Catalogue transverse : on expose ici une LISTE complète
|
||||||
|
* (avec catégories, pour regroupement par familles côté frontend), utilisée par le sélecteur
|
||||||
|
* "Enveloppe" de Paramètres > Mes comptes (Phase 3a, cf. plan de déploiement Enveloppes &
|
||||||
|
* Supports). Le champ `eligible_compte` permet au frontend de ne proposer, dans ce sélecteur,
|
||||||
|
* que le sous-ensemble "compte financier" — cadrage validé par Olivier via AskUserQuestion
|
||||||
|
* (28/09/26) — sans empêcher un futur écran (Phase 3b) de consommer la liste complète.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
// Monté sous requireAuth (sans requireAdmin) — lecture seule.
|
||||||
|
|
||||||
|
function attachCategories(rows) {
|
||||||
|
if (rows.length === 0) return rows;
|
||||||
|
const ids = rows.map(r => r.id);
|
||||||
|
const links = db.prepare(`
|
||||||
|
SELECT rc.enveloppe_id, c.id, c.nom
|
||||||
|
FROM referentiel_categories_enveloppes rc
|
||||||
|
JOIN categories_enveloppes c ON c.id = rc.categorie_id
|
||||||
|
WHERE rc.enveloppe_id IN (${ids.map(() => '?').join(',')})
|
||||||
|
ORDER BY c.nom
|
||||||
|
`).all(...ids);
|
||||||
|
const map = {};
|
||||||
|
for (const l of links) {
|
||||||
|
if (!map[l.enveloppe_id]) map[l.enveloppe_id] = [];
|
||||||
|
map[l.enveloppe_id].push({ id: l.id, nom: l.nom });
|
||||||
|
}
|
||||||
|
return rows.map(r => ({ ...r, categories: map[r.id] || [] }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* GET /api/enveloppes-referentiel-public — liste complète (lecture seule) */
|
||||||
|
router.get('/', (_req, res) => {
|
||||||
|
const rows = db.prepare(`
|
||||||
|
SELECT id, nom, eligible_compte
|
||||||
|
FROM enveloppes_referentiel
|
||||||
|
ORDER BY nom
|
||||||
|
`).all();
|
||||||
|
res.json(attachCategories(rows));
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -54,7 +54,9 @@ import institutionsReferentielRouter from './routes/institutionsReferentiel.js';
|
|||||||
import institutionsReferentielPublicRouter from './routes/institutionsReferentielPublic.js';
|
import institutionsReferentielPublicRouter from './routes/institutionsReferentielPublic.js';
|
||||||
import refCategoriesInstitutionsRouter from './routes/ref-categories-institutions.js';
|
import refCategoriesInstitutionsRouter from './routes/ref-categories-institutions.js';
|
||||||
import enveloppesReferentielRouter from './routes/enveloppesReferentiel.js';
|
import enveloppesReferentielRouter from './routes/enveloppesReferentiel.js';
|
||||||
|
import enveloppesReferentielPublicRouter from './routes/enveloppesReferentielPublic.js';
|
||||||
import supportsReferentielRouter from './routes/supportsReferentiel.js';
|
import supportsReferentielRouter from './routes/supportsReferentiel.js';
|
||||||
|
import empruntsRouter from './routes/emprunts.js';
|
||||||
import refCategoriesEnveloppesRouter from './routes/ref-categories-enveloppes.js';
|
import refCategoriesEnveloppesRouter from './routes/ref-categories-enveloppes.js';
|
||||||
import refCategoriesRouter from './routes/ref-categories.js';
|
import refCategoriesRouter from './routes/ref-categories.js';
|
||||||
import refSecteursRouter from './routes/ref-secteurs.js';
|
import refSecteursRouter from './routes/ref-secteurs.js';
|
||||||
@@ -161,6 +163,7 @@ app.use('/api/reinvestissements', requireAuth, reinvestissementsRouter);
|
|||||||
app.use('/api/frais-operations', requireAuth, fraisOperationsRouter);
|
app.use('/api/frais-operations', requireAuth, fraisOperationsRouter);
|
||||||
app.use('/api/corrections', requireAuth, correctionsRouter);
|
app.use('/api/corrections', requireAuth, correctionsRouter);
|
||||||
app.use('/api/comptes', requireAuth, comptesRouter);
|
app.use('/api/comptes', requireAuth, comptesRouter);
|
||||||
|
app.use('/api/emprunts', requireAuth, empruntsRouter);
|
||||||
app.use('/api/preferences', requireAuth, preferencesRouter);
|
app.use('/api/preferences', requireAuth, preferencesRouter);
|
||||||
app.use('/api/icons', requireAuth, iconsRouter);
|
app.use('/api/icons', requireAuth, iconsRouter);
|
||||||
app.use('/api/admin', requireAuth, requireAdmin, adminRouter);
|
app.use('/api/admin', requireAuth, requireAdmin, adminRouter);
|
||||||
@@ -183,6 +186,7 @@ app.use('/api/ref-categories-institutions', requireAuth, requireAdmin, refCateg
|
|||||||
// catalogue admin transverse aux workspaces, catalogue seul pour l'instant (pas de
|
// catalogue admin transverse aux workspaces, catalogue seul pour l'instant (pas de
|
||||||
// rattachement à l'existant), cf. enveloppesReferentiel.js.
|
// rattachement à l'existant), cf. enveloppesReferentiel.js.
|
||||||
app.use('/api/enveloppes-referentiel', requireAuth, requireAdmin, enveloppesReferentielRouter);
|
app.use('/api/enveloppes-referentiel', requireAuth, requireAdmin, enveloppesReferentielRouter);
|
||||||
|
app.use('/api/enveloppes-referentiel-public', requireAuth, enveloppesReferentielPublicRouter);
|
||||||
app.use('/api/supports-referentiel', requireAuth, requireAdmin, supportsReferentielRouter);
|
app.use('/api/supports-referentiel', requireAuth, requireAdmin, supportsReferentielRouter);
|
||||||
app.use('/api/ref-categories-enveloppes', requireAuth, requireAdmin, refCategoriesEnveloppesRouter);
|
app.use('/api/ref-categories-enveloppes', requireAuth, requireAdmin, refCategoriesEnveloppesRouter);
|
||||||
app.use('/api/ref-categories', requireAuth, requireAdmin, refCategoriesRouter);
|
app.use('/api/ref-categories', requireAuth, requireAdmin, refCategoriesRouter);
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg version="1.2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1583 1007" width="1583" height="1007"><style>.a{fill:#00b9f2}.b{fill:#021c41}</style><path class="a" d="m6.5 869.1h1576.5v137.3h-1576.5z"/><path fill-rule="evenodd" class="b" d="m428.3 0.8h-124.2l-303.9 728.7h171.8l59.4-141.8h269.5l59.4 141.8h171.8zm-149.3 448.5l87.1-227.4v0.1l0.1-0.1 87.1 227.4zm625.2-253.3h-149.8v533.4h163v-369.1c12.8-11.9 27.5-22.3 43.2-29.9 18.2-8.8 63.3-24.7 95.9 2.6 25.3 21.2 26.3 71.2 26.3 85.4v311h161.9v-319.5c0-19.5-2.2-38.7-5.3-57.9 14.8-10.1 31.5-17.3 47.8-24.5 37.1-16.5 79.5-8.3 93.3 1.3 29.9 20.8 32.8 68.1 32.8 89.6v311h161.9v-317.3c0-58.3-8.2-131.7-60.5-179.1-52.8-47.8-134.1-49-151.1-49.5-100.2 1.5-176 55.2-176 55.2-36.1-36.9-124.8-71.3-197.6-45.6-8 2.8-47.2 15.2-79.2 46.5z"/></svg>
|
||||||
|
After Width: | Height: | Size: 789 B |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
@@ -64,6 +64,7 @@ function EnveloppeModal({ row, onClose, onSaved }) {
|
|||||||
description: row?.description || '',
|
description: row?.description || '',
|
||||||
plafond_eur: row?.plafond_eur != null ? String(row.plafond_eur) : '',
|
plafond_eur: row?.plafond_eur != null ? String(row.plafond_eur) : '',
|
||||||
regime_fiscal: row?.regime_fiscal || '',
|
regime_fiscal: row?.regime_fiscal || '',
|
||||||
|
eligible_compte: !!row?.eligible_compte,
|
||||||
categories_ids: (row?.categories || []).map(c => c.id),
|
categories_ids: (row?.categories || []).map(c => c.id),
|
||||||
supports_ids: (row?.supports || []).map(s => s.id),
|
supports_ids: (row?.supports || []).map(s => s.id),
|
||||||
});
|
});
|
||||||
@@ -87,6 +88,7 @@ function EnveloppeModal({ row, onClose, onSaved }) {
|
|||||||
description: form.description.trim() || null,
|
description: form.description.trim() || null,
|
||||||
plafond_eur: plafond,
|
plafond_eur: plafond,
|
||||||
regime_fiscal: form.regime_fiscal.trim() || null,
|
regime_fiscal: form.regime_fiscal.trim() || null,
|
||||||
|
eligible_compte: !!form.eligible_compte,
|
||||||
categories_ids: form.categories_ids || [],
|
categories_ids: form.categories_ids || [],
|
||||||
supports_ids: form.supports_ids || [],
|
supports_ids: form.supports_ids || [],
|
||||||
};
|
};
|
||||||
@@ -128,6 +130,18 @@ function EnveloppeModal({ row, onClose, onSaved }) {
|
|||||||
<input value={form.regime_fiscal} onChange={e => set('regime_fiscal', e.target.value)} placeholder="Ex : Exonéré IR/PS, Flat tax 30%…" />
|
<input value={form.regime_fiscal} onChange={e => set('regime_fiscal', e.target.value)} placeholder="Ex : Exonéré IR/PS, Flat tax 30%…" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}>
|
||||||
|
<input type="checkbox" checked={!!form.eligible_compte} style={{ width: 'auto' }}
|
||||||
|
onChange={e => set('eligible_compte', e.target.checked)} />
|
||||||
|
Éligible comme type de compte (Paramètres > Mes comptes courants)
|
||||||
|
</label>
|
||||||
|
<p style={{ margin: '2px 0 0', fontSize: 11, color: 'var(--text-muted)' }}>
|
||||||
|
À cocher pour les enveloppes qui ont le profil d'un vrai compte financier (banque,
|
||||||
|
courtier, assureur…) — pas pour les actifs détenus en direct (immobilier, crypto
|
||||||
|
auto-conservée, objets de collection…).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label>Catégories</label>
|
<label>Catégories</label>
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||||
@@ -302,7 +316,15 @@ function EnveloppesSection() {
|
|||||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--surface-2)'}
|
onMouseEnter={e => e.currentTarget.style.background = 'var(--surface-2)'}
|
||||||
onMouseLeave={e => e.currentTarget.style.background = 'none'}>
|
onMouseLeave={e => e.currentTarget.style.background = 'none'}>
|
||||||
<td style={{ padding: '10px 16px' }}>
|
<td style={{ padding: '10px 16px' }}>
|
||||||
<div style={{ fontWeight: 600 }}>{row.nom}</div>
|
<div style={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
{row.nom}
|
||||||
|
{!!row.eligible_compte && (
|
||||||
|
<span title="Éligible comme type de compte (Paramètres > Mes comptes courants)"
|
||||||
|
style={{ fontSize: 10, fontWeight: 600, color: 'var(--primary)', border: '1px solid var(--primary)', borderRadius: 4, padding: '1px 5px' }}>
|
||||||
|
Compte
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{row.description && <div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 2 }}>{row.description}</div>}
|
{row.description && <div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 2 }}>{row.description}</div>}
|
||||||
</td>
|
</td>
|
||||||
<td style={{ padding: '10px 8px', color: 'var(--text-muted)', fontSize: 12 }}>{eur(row.plafond_eur)}</td>
|
<td style={{ padding: '10px 8px', color: 'var(--text-muted)', fontSize: 12 }}>{eur(row.plafond_eur)}</td>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import PlateformesSection from './settings/PlateformesSection.jsx';
|
|||||||
import CategoriesInvSection from './settings/CategoriesInvSection.jsx';
|
import CategoriesInvSection from './settings/CategoriesInvSection.jsx';
|
||||||
import SecteursInvSection from './settings/SecteursInvSection.jsx';
|
import SecteursInvSection from './settings/SecteursInvSection.jsx';
|
||||||
import ComptesSection from './settings/ComptesSection.jsx';
|
import ComptesSection from './settings/ComptesSection.jsx';
|
||||||
|
import EmpruntsSection from './settings/EmpruntsSection.jsx';
|
||||||
import MaFiscaliteSection from './settings/MaFiscaliteSection.jsx';
|
import MaFiscaliteSection from './settings/MaFiscaliteSection.jsx';
|
||||||
import ObjectifsSection from './settings/ObjectifsSection.jsx';
|
import ObjectifsSection from './settings/ObjectifsSection.jsx';
|
||||||
import DataCleanupSection from './settings/DataCleanupSection.jsx';
|
import DataCleanupSection from './settings/DataCleanupSection.jsx';
|
||||||
@@ -16,6 +17,7 @@ function IconFamily() { return <svg width="15" height="15" viewBox="0 0 24 24"
|
|||||||
function IconMonitor() { 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="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg>; }
|
function IconMonitor() { 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="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg>; }
|
||||||
function IconServer() { 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="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>; }
|
function IconServer() { 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="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>; }
|
||||||
function IconWallet() { 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="2" y="5" width="20" height="14" rx="2"/><path d="M16 12h.01M2 10h20"/></svg>; }
|
function IconWallet() { 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="2" y="5" width="20" height="14" rx="2"/><path d="M16 12h.01M2 10h20"/></svg>; }
|
||||||
|
function IconLoan() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg>; }
|
||||||
function IconTag() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/></svg>; }
|
function IconTag() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/></svg>; }
|
||||||
function IconLayers() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><polygon points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/></svg>; }
|
function IconLayers() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><polygon points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/></svg>; }
|
||||||
function IconGrid() { 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"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>; }
|
function IconGrid() { 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"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>; }
|
||||||
@@ -44,6 +46,7 @@ const NAV = [
|
|||||||
{ id: 'membres', label: 'Mes membres & entreprises', icon: <IconFamily /> },
|
{ id: 'membres', label: 'Mes membres & entreprises', icon: <IconFamily /> },
|
||||||
{ id: 'plateformes', label: 'Mes plateformes', icon: <IconServer /> },
|
{ id: 'plateformes', label: 'Mes plateformes', icon: <IconServer /> },
|
||||||
{ id: 'comptes', label: 'Mes comptes courants', icon: <IconWallet /> },
|
{ id: 'comptes', label: 'Mes comptes courants', icon: <IconWallet /> },
|
||||||
|
{ id: 'emprunts', label: 'Mes emprunts', icon: <IconLoan /> },
|
||||||
{ id: 'ma-fiscalite', label: 'Ma fiscalité', icon: <IconMyFiscal /> },
|
{ id: 'ma-fiscalite', label: 'Ma fiscalité', icon: <IconMyFiscal /> },
|
||||||
{ id: 'objectifs', label: 'Fixation des objectifs', icon: <IconTarget /> },
|
{ id: 'objectifs', label: 'Fixation des objectifs', icon: <IconTarget /> },
|
||||||
],
|
],
|
||||||
@@ -97,6 +100,7 @@ export default function Settings() {
|
|||||||
{section === 'membres' && <FamilleEntreprises />}
|
{section === 'membres' && <FamilleEntreprises />}
|
||||||
{section === 'plateformes' && <PlateformesSection />}
|
{section === 'plateformes' && <PlateformesSection />}
|
||||||
{section === 'comptes' && <ComptesSection />}
|
{section === 'comptes' && <ComptesSection />}
|
||||||
|
{section === 'emprunts' && <EmpruntsSection />}
|
||||||
{section === 'ma-fiscalite' && <MaFiscaliteSection />}
|
{section === 'ma-fiscalite' && <MaFiscaliteSection />}
|
||||||
{section === 'objectifs' && <ObjectifsSection />}
|
{section === 'objectifs' && <ObjectifsSection />}
|
||||||
{section === 'categories-inv' && <CategoriesInvSection />}
|
{section === 'categories-inv' && <CategoriesInvSection />}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ const TYPE_COMPTE_LABELS = {
|
|||||||
pea_pme: 'PEA-PME',
|
pea_pme: 'PEA-PME',
|
||||||
};
|
};
|
||||||
|
|
||||||
const EMPTY_COMPTE = { nom: '', type: 'compte_courant', investisseur_id: null, institution_id: null, banque: '', exoneration_fiscale: 'aucune' };
|
const EMPTY_COMPTE = { nom: '', type: 'compte_courant', investisseur_id: null, institution_id: null, banque: '', enveloppe_id: null, exoneration_fiscale: 'aucune' };
|
||||||
|
|
||||||
// Référentiel institutions financières (Phase 2, 26/09/26) — logos servis au même endroit
|
// Référentiel institutions financières (Phase 2, 26/09/26) — logos servis au même endroit
|
||||||
// que ceux des plateformes (/api/logos/:filename), cf. institutionsReferentiel.js.
|
// que ceux des plateformes (/api/logos/:filename), cf. institutionsReferentiel.js.
|
||||||
@@ -28,8 +28,15 @@ function compteInvestisseur(c) {
|
|||||||
return { id: c.investisseur_id, nom: c.investisseur_nom, prenom: c.investisseur_prenom, type: c.investisseur_type, type_fiscal: c.investisseur_type_fiscal };
|
return { id: c.investisseur_id, nom: c.investisseur_nom, prenom: c.investisseur_prenom, type: c.investisseur_type, type_fiscal: c.investisseur_type_fiscal };
|
||||||
}
|
}
|
||||||
|
|
||||||
function CompteFormFields({ state, setter, investisseurs, institutions }) {
|
function CompteFormFields({ state, setter, investisseurs, institutions, enveloppes }) {
|
||||||
const isAutre = !state.institution_id;
|
const isAutre = !state.institution_id;
|
||||||
|
// Regroupement par catégorie pour le <select> Enveloppe (cf. enveloppesReferentielPublic.js) —
|
||||||
|
// seules les enveloppes eligible_compte=1 sont proposées ici (cadrage Olivier, 28/09/26).
|
||||||
|
const enveloppesParCategorie = {};
|
||||||
|
for (const env of enveloppes) {
|
||||||
|
const cat = (env.categories && env.categories[0]) ? env.categories[0].nom : 'Autre';
|
||||||
|
(enveloppesParCategorie[cat] ??= []).push(env);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div>
|
<div>
|
||||||
@@ -72,6 +79,21 @@ function CompteFormFields({ state, setter, investisseurs, institutions }) {
|
|||||||
placeholder="ex. BNP Paribas — absente du référentiel ?" />
|
placeholder="ex. BNP Paribas — absente du référentiel ?" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<div>
|
||||||
|
<label>Enveloppe</label>
|
||||||
|
<select value={state.enveloppe_id ?? ''}
|
||||||
|
onChange={e => setter({ ...state, enveloppe_id: e.target.value ? Number(e.target.value) : null })}>
|
||||||
|
<option value="">— Non renseignée —</option>
|
||||||
|
{Object.entries(enveloppesParCategorie).map(([cat, envs]) => (
|
||||||
|
<optgroup key={cat} label={cat}>
|
||||||
|
{envs.map(env => <option key={env.id} value={env.id}>{env.nom}</option>)}
|
||||||
|
</optgroup>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<p style={{ margin: '4px 0 0', fontSize: 11, color: 'var(--text-muted)' }}>
|
||||||
|
Rattachement au référentiel (Admin > Enveloppes & supports) — informatif, sans effet sur le champ Type ci-dessus.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label>Détenteur</label>
|
<label>Détenteur</label>
|
||||||
<select value={state.investisseur_id ?? ''}
|
<select value={state.investisseur_id ?? ''}
|
||||||
@@ -98,16 +120,21 @@ export default function ComptesSection() {
|
|||||||
const [comptes, setComptes] = useState([]);
|
const [comptes, setComptes] = useState([]);
|
||||||
const [investisseurs, setInvestisseurs] = useState([]);
|
const [investisseurs, setInvestisseurs] = useState([]);
|
||||||
const [institutions, setInstitutions] = useState([]);
|
const [institutions, setInstitutions] = useState([]);
|
||||||
|
const [enveloppes, setEnveloppes] = useState([]);
|
||||||
|
|
||||||
const reload = useCallback(async () => {
|
const reload = useCallback(async () => {
|
||||||
const [cpts, invs, insts] = await Promise.all([
|
const [cpts, invs, insts, envs] = await Promise.all([
|
||||||
api.get('/comptes'),
|
api.get('/comptes'),
|
||||||
api.get('/investisseurs'),
|
api.get('/investisseurs'),
|
||||||
api.get('/institutions-referentiel-public'),
|
api.get('/institutions-referentiel-public'),
|
||||||
|
api.get('/enveloppes-referentiel-public'),
|
||||||
]);
|
]);
|
||||||
setComptes(cpts);
|
setComptes(cpts);
|
||||||
setInvestisseurs(invs);
|
setInvestisseurs(invs);
|
||||||
setInstitutions(insts);
|
setInstitutions(insts);
|
||||||
|
// Sous-ensemble "compte financier" uniquement (cadrage Olivier, 28/09/26) — cf.
|
||||||
|
// enveloppesReferentielPublic.js et la migration eligible_compte dans db/index.js.
|
||||||
|
setEnveloppes(envs.filter(e => e.eligible_compte));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => { reload(); }, [reload]);
|
useEffect(() => { reload(); }, [reload]);
|
||||||
@@ -129,6 +156,7 @@ export default function ComptesSection() {
|
|||||||
type: newCompte.type,
|
type: newCompte.type,
|
||||||
banque: newCompte.banque || null,
|
banque: newCompte.banque || null,
|
||||||
institution_id: newCompte.institution_id ?? null,
|
institution_id: newCompte.institution_id ?? null,
|
||||||
|
enveloppe_id: newCompte.enveloppe_id ?? null,
|
||||||
investisseur_id: newCompte.investisseur_id ? Number(newCompte.investisseur_id) : null,
|
investisseur_id: newCompte.investisseur_id ? Number(newCompte.investisseur_id) : null,
|
||||||
exoneration_fiscale: newCompte.exoneration_fiscale ?? 'aucune',
|
exoneration_fiscale: newCompte.exoneration_fiscale ?? 'aucune',
|
||||||
});
|
});
|
||||||
@@ -144,6 +172,7 @@ export default function ComptesSection() {
|
|||||||
type: editCompte.type,
|
type: editCompte.type,
|
||||||
banque: editCompte.banque || null,
|
banque: editCompte.banque || null,
|
||||||
institution_id: editCompte.institution_id ?? null,
|
institution_id: editCompte.institution_id ?? null,
|
||||||
|
enveloppe_id: editCompte.enveloppe_id ?? null,
|
||||||
investisseur_id: editCompte.investisseur_id ? Number(editCompte.investisseur_id) : null,
|
investisseur_id: editCompte.investisseur_id ? Number(editCompte.investisseur_id) : null,
|
||||||
exoneration_fiscale: editCompte.exoneration_fiscale ?? 'aucune',
|
exoneration_fiscale: editCompte.exoneration_fiscale ?? 'aucune',
|
||||||
});
|
});
|
||||||
@@ -153,7 +182,7 @@ export default function ComptesSection() {
|
|||||||
|
|
||||||
const openEdit = (c) => {
|
const openEdit = (c) => {
|
||||||
setErr(null);
|
setErr(null);
|
||||||
setEditCompte({ id: c.id, nom: c.nom, type: c.type, banque: c.banque || '', institution_id: c.institution_id ?? null, investisseur_id: c.investisseur_id ?? null, exoneration_fiscale: c.exoneration_fiscale ?? 'aucune' });
|
setEditCompte({ id: c.id, nom: c.nom, type: c.type, banque: c.banque || '', institution_id: c.institution_id ?? null, enveloppe_id: c.enveloppe_id ?? null, investisseur_id: c.investisseur_id ?? null, exoneration_fiscale: c.exoneration_fiscale ?? 'aucune' });
|
||||||
};
|
};
|
||||||
|
|
||||||
const del = (c) => {
|
const del = (c) => {
|
||||||
@@ -190,16 +219,17 @@ export default function ComptesSection() {
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Nom</th>
|
<th>Nom</th>
|
||||||
<th style={{ width: '14%' }}>Type</th>
|
<th style={{ width: '13%' }}>Type</th>
|
||||||
{multiDetenteur && <th style={{ width: '22%' }}>Détenteur</th>}
|
{multiDetenteur && <th style={{ width: '18%' }}>Détenteur</th>}
|
||||||
<th style={{ width: '16%' }}>Banque</th>
|
<th style={{ width: '14%' }}>Banque</th>
|
||||||
|
<th style={{ width: '16%' }}>Enveloppe</th>
|
||||||
<th style={{ width: '10%' }}>Exonération</th>
|
<th style={{ width: '10%' }}>Exonération</th>
|
||||||
<th style={{ width: 80 }} />
|
<th style={{ width: 80 }} />
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{comptes.length === 0 && (
|
{comptes.length === 0 && (
|
||||||
<tr><td colSpan={multiDetenteur ? 6 : 5} className="text-muted" style={{ textAlign: 'center', fontStyle: 'italic' }}>
|
<tr><td colSpan={multiDetenteur ? 7 : 6} className="text-muted" style={{ textAlign: 'center', fontStyle: 'italic' }}>
|
||||||
Aucun compte défini.
|
Aucun compte défini.
|
||||||
</td></tr>
|
</td></tr>
|
||||||
)}
|
)}
|
||||||
@@ -221,6 +251,7 @@ export default function ComptesSection() {
|
|||||||
</span>
|
</span>
|
||||||
) : (c.banque || <span className="text-muted">—</span>)}
|
) : (c.banque || <span className="text-muted">—</span>)}
|
||||||
</td>
|
</td>
|
||||||
|
<td>{c.enveloppe_nom || <span className="text-muted">—</span>}</td>
|
||||||
<td>
|
<td>
|
||||||
{c.exoneration_fiscale && c.exoneration_fiscale !== 'aucune' ? (
|
{c.exoneration_fiscale && c.exoneration_fiscale !== 'aucune' ? (
|
||||||
<span
|
<span
|
||||||
@@ -259,7 +290,7 @@ export default function ComptesSection() {
|
|||||||
>
|
>
|
||||||
<form id="form-new-compte" onSubmit={e => runAddCompte(addCompte, e)} style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
<form id="form-new-compte" onSubmit={e => runAddCompte(addCompte, e)} style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
{err && <div className="error">{err}</div>}
|
{err && <div className="error">{err}</div>}
|
||||||
<CompteFormFields state={newCompte} setter={setNewCompte} investisseurs={investisseurs} institutions={institutions} />
|
<CompteFormFields state={newCompte} setter={setNewCompte} investisseurs={investisseurs} institutions={institutions} enveloppes={enveloppes} />
|
||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
@@ -283,7 +314,7 @@ export default function ComptesSection() {
|
|||||||
>
|
>
|
||||||
<form id="form-edit-compte" onSubmit={e => runSaveEdit(saveEdit, e)} style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
<form id="form-edit-compte" onSubmit={e => runSaveEdit(saveEdit, e)} style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
{err && <div className="error">{err}</div>}
|
{err && <div className="error">{err}</div>}
|
||||||
<CompteFormFields state={editCompte} setter={setEditCompte} investisseurs={investisseurs} institutions={institutions} />
|
<CompteFormFields state={editCompte} setter={setEditCompte} investisseurs={investisseurs} institutions={institutions} enveloppes={enveloppes} />
|
||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,305 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { api } from '../../api.js';
|
||||||
|
import { memberLabel } from '../../utils/format.js';
|
||||||
|
import ConfirmModal from '../../components/ConfirmModal.jsx';
|
||||||
|
import Modal from '../../components/Modal.jsx';
|
||||||
|
import { useAsyncAction } from '../../hooks/useAsyncAction.js';
|
||||||
|
|
||||||
|
// Paramètres > Mes emprunts (Phase 3c, 28/09/26, demande Olivier) — passif patrimonial,
|
||||||
|
// symétrique de Paramètres > Mes comptes courants, cf. routes/emprunts.js et la table dédiée
|
||||||
|
// dans db/index.js. Un emprunt est structurellement différent d'un compte/enveloppe (capital
|
||||||
|
// restant dû qui diminue, taux, échéancier) — objet séparé plutôt qu'un enrichissement de
|
||||||
|
// comptes.js.
|
||||||
|
|
||||||
|
const EMPTY_EMPRUNT = {
|
||||||
|
nom: '', investisseur_id: null, institution_id: null,
|
||||||
|
montant_emprunte: '', capital_restant_du: '', taux_nominal: '',
|
||||||
|
date_debut: '', duree_mois: '', mensualite: '', notes: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
const API_BASE = (import.meta.env.VITE_API_URL || '/api').replace(/\/api$/, '');
|
||||||
|
function institutionLogoUrl(institutionIconeOrLogo) {
|
||||||
|
return institutionIconeOrLogo ? `${API_BASE}/api/logos/${institutionIconeOrLogo}` : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function empruntInvestisseur(e) {
|
||||||
|
if (!e.investisseur_id) return null;
|
||||||
|
return { id: e.investisseur_id, nom: e.investisseur_nom, prenom: e.investisseur_prenom, type: e.investisseur_type, type_fiscal: e.investisseur_type_fiscal };
|
||||||
|
}
|
||||||
|
|
||||||
|
function eur(n) {
|
||||||
|
return n == null || n === '' ? null : new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 }).format(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmpruntFormFields({ state, setter, investisseurs, institutions }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<label>Nom *</label>
|
||||||
|
<input required value={state.nom}
|
||||||
|
onChange={e => setter({ ...state, nom: e.target.value })}
|
||||||
|
placeholder="ex. Crédit immobilier résidence principale" />
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||||
|
<div>
|
||||||
|
<label>Montant emprunté (€) *</label>
|
||||||
|
<input required type="number" min="0" step="0.01" value={state.montant_emprunte}
|
||||||
|
onChange={e => setter({ ...state, montant_emprunte: e.target.value })} placeholder="ex. 250000" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Capital restant dû (€) *</label>
|
||||||
|
<input required type="number" min="0" step="0.01" value={state.capital_restant_du}
|
||||||
|
onChange={e => setter({ ...state, capital_restant_du: e.target.value })} placeholder="ex. 230000" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 12 }}>
|
||||||
|
<div>
|
||||||
|
<label>Taux nominal (%)</label>
|
||||||
|
<input type="number" min="0" step="0.01" value={state.taux_nominal}
|
||||||
|
onChange={e => setter({ ...state, taux_nominal: e.target.value })} placeholder="ex. 3.2" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Date de début</label>
|
||||||
|
<input type="date" value={state.date_debut || ''}
|
||||||
|
onChange={e => setter({ ...state, date_debut: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Durée (mois)</label>
|
||||||
|
<input type="number" min="1" step="1" value={state.duree_mois}
|
||||||
|
onChange={e => setter({ ...state, duree_mois: e.target.value })} placeholder="ex. 240" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||||
|
<div>
|
||||||
|
<label>Mensualité (€)</label>
|
||||||
|
<input type="number" min="0" step="0.01" value={state.mensualite}
|
||||||
|
onChange={e => setter({ ...state, mensualite: e.target.value })} placeholder="ex. 1400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Établissement prêteur</label>
|
||||||
|
<select value={state.institution_id ?? ''}
|
||||||
|
onChange={e => setter({ ...state, institution_id: e.target.value ? Number(e.target.value) : null })}>
|
||||||
|
<option value="">— Non renseigné —</option>
|
||||||
|
{institutions.map(inst => (
|
||||||
|
<option key={inst.id} value={inst.id}>{inst.nom}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Détenteur</label>
|
||||||
|
<select value={state.investisseur_id ?? ''}
|
||||||
|
onChange={e => setter({ ...state, investisseur_id: e.target.value ? Number(e.target.value) : null })}>
|
||||||
|
<option value="">— Non renseigné —</option>
|
||||||
|
{investisseurs.map(inv => (
|
||||||
|
<option key={inv.id} value={inv.id}>{memberLabel(inv)}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Notes</label>
|
||||||
|
<textarea value={state.notes || ''} onChange={e => setter({ ...state, notes: e.target.value })}
|
||||||
|
style={{ minHeight: 70, resize: 'vertical' }} placeholder="Informations complémentaires…" />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EmpruntsSection() {
|
||||||
|
const [emprunts, setEmprunts] = useState([]);
|
||||||
|
const [investisseurs, setInvestisseurs] = useState([]);
|
||||||
|
const [institutions, setInstitutions] = useState([]);
|
||||||
|
|
||||||
|
const reload = useCallback(async () => {
|
||||||
|
const [emps, invs, insts] = await Promise.all([
|
||||||
|
api.get('/emprunts'),
|
||||||
|
api.get('/investisseurs'),
|
||||||
|
api.get('/institutions-referentiel-public'),
|
||||||
|
]);
|
||||||
|
setEmprunts(emps);
|
||||||
|
setInvestisseurs(invs);
|
||||||
|
setInstitutions(insts);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { reload(); }, [reload]);
|
||||||
|
const [showNew, setShowNew] = useState(false);
|
||||||
|
const [newEmprunt, setNewEmprunt] = useState(EMPTY_EMPRUNT);
|
||||||
|
const [editEmprunt, setEditEmprunt] = useState(null);
|
||||||
|
const [err, setErr] = useState(null);
|
||||||
|
const [confirmDel, setConfirmDel] = useState(null);
|
||||||
|
const { run: runAddEmprunt, pending: addingEmprunt } = useAsyncAction();
|
||||||
|
const { run: runSaveEdit, pending: savingEdit } = useAsyncAction();
|
||||||
|
|
||||||
|
const openNew = () => { setNewEmprunt(EMPTY_EMPRUNT); setErr(null); setShowNew(true); };
|
||||||
|
|
||||||
|
const toPayload = (state) => ({
|
||||||
|
nom: state.nom,
|
||||||
|
investisseur_id: state.investisseur_id ? Number(state.investisseur_id) : null,
|
||||||
|
institution_id: state.institution_id ? Number(state.institution_id) : null,
|
||||||
|
montant_emprunte: Number(state.montant_emprunte),
|
||||||
|
capital_restant_du: Number(state.capital_restant_du),
|
||||||
|
taux_nominal: state.taux_nominal !== '' && state.taux_nominal != null ? Number(state.taux_nominal) : null,
|
||||||
|
date_debut: state.date_debut || null,
|
||||||
|
duree_mois: state.duree_mois !== '' && state.duree_mois != null ? Number(state.duree_mois) : null,
|
||||||
|
mensualite: state.mensualite !== '' && state.mensualite != null ? Number(state.mensualite) : null,
|
||||||
|
notes: state.notes || null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const addEmprunt = async (e) => {
|
||||||
|
e.preventDefault(); setErr(null);
|
||||||
|
try {
|
||||||
|
await api.post('/emprunts', toPayload(newEmprunt));
|
||||||
|
setShowNew(false); setNewEmprunt(EMPTY_EMPRUNT); reload();
|
||||||
|
} catch (ex) { setErr(ex.message); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveEdit = async (e) => {
|
||||||
|
e.preventDefault(); setErr(null);
|
||||||
|
try {
|
||||||
|
await api.put(`/emprunts/${editEmprunt.id}`, toPayload(editEmprunt));
|
||||||
|
setEditEmprunt(null); reload();
|
||||||
|
} catch (ex) { setErr(ex.message); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (e) => {
|
||||||
|
setErr(null);
|
||||||
|
setEditEmprunt({
|
||||||
|
id: e.id, nom: e.nom, investisseur_id: e.investisseur_id ?? null, institution_id: e.institution_id ?? null,
|
||||||
|
montant_emprunte: e.montant_emprunte ?? '', capital_restant_du: e.capital_restant_du ?? '',
|
||||||
|
taux_nominal: e.taux_nominal ?? '', date_debut: e.date_debut || '', duree_mois: e.duree_mois ?? '',
|
||||||
|
mensualite: e.mensualite ?? '', notes: e.notes || '',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const del = (e) => {
|
||||||
|
setConfirmDel({
|
||||||
|
message: `Supprimer l'emprunt "${e.nom}" ?`,
|
||||||
|
onConfirm: async () => {
|
||||||
|
try { await api.del(`/emprunts/${e.id}`); reload(); }
|
||||||
|
catch (ex) { setErr(ex.message); }
|
||||||
|
finally { setConfirmDel(null); }
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const multiDetenteur = investisseurs.length > 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="card">
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||||
|
<div>
|
||||||
|
<h3 style={{ margin: 0 }}>Mes emprunts</h3>
|
||||||
|
<p className="text-muted" style={{ fontSize: 'var(--fs-sm)', margin: '4px 0 0' }}>
|
||||||
|
Passif patrimonial — crédits en cours, distincts des comptes et enveloppes d'actifs.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button className="primary" style={{ whiteSpace: 'nowrap' }} onClick={openNew}>
|
||||||
|
+ Nouvel emprunt
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{err && <div className="error" style={{ marginBottom: 10 }}>{err}</div>}
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Nom</th>
|
||||||
|
<th style={{ width: '14%' }}>Montant emprunté</th>
|
||||||
|
<th style={{ width: '14%' }}>Capital restant dû</th>
|
||||||
|
{multiDetenteur && <th style={{ width: '18%' }}>Détenteur</th>}
|
||||||
|
<th style={{ width: '16%' }}>Prêteur</th>
|
||||||
|
<th style={{ width: 80 }} />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{emprunts.length === 0 && (
|
||||||
|
<tr><td colSpan={multiDetenteur ? 6 : 5} className="text-muted" style={{ textAlign: 'center', fontStyle: 'italic' }}>
|
||||||
|
Aucun emprunt défini.
|
||||||
|
</td></tr>
|
||||||
|
)}
|
||||||
|
{emprunts.map(e => {
|
||||||
|
const inv = empruntInvestisseur(e);
|
||||||
|
return (
|
||||||
|
<tr key={e.id}>
|
||||||
|
<td style={{ fontWeight: 500 }}>{e.nom}</td>
|
||||||
|
<td>{eur(e.montant_emprunte)}</td>
|
||||||
|
<td>{eur(e.capital_restant_du)}</td>
|
||||||
|
{multiDetenteur && <td>{inv ? memberLabel(inv) : <span className="text-muted">—</span>}</td>}
|
||||||
|
<td>
|
||||||
|
{e.institution_id ? (
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
{institutionLogoUrl(e.institution_icone_filename || e.institution_logo_filename) && (
|
||||||
|
<img src={institutionLogoUrl(e.institution_icone_filename || e.institution_logo_filename)} alt=""
|
||||||
|
style={{ width: 18, height: 18, objectFit: 'contain', borderRadius: 3, flexShrink: 0 }} />
|
||||||
|
)}
|
||||||
|
{e.institution_nom}
|
||||||
|
</span>
|
||||||
|
) : <span className="text-muted">—</span>}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div style={{ display: 'flex', gap: 4 }}>
|
||||||
|
<button style={{ padding: '3px 10px', fontSize: 11 }} onClick={() => openEdit(e)}>Modifier</button>
|
||||||
|
<button className="danger" style={{ padding: '3px 10px', fontSize: 11 }} onClick={() => del(e)}>✕</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Modal création ── */}
|
||||||
|
<Modal
|
||||||
|
open={showNew}
|
||||||
|
title="Nouvel emprunt"
|
||||||
|
onClose={() => { setShowNew(false); setNewEmprunt(EMPTY_EMPRUNT); setErr(null); }}
|
||||||
|
busy={addingEmprunt}
|
||||||
|
footer={
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', width: '100%', gap: 8 }}>
|
||||||
|
<button type="button" disabled={addingEmprunt} onClick={() => { setShowNew(false); setNewEmprunt(EMPTY_EMPRUNT); setErr(null); }}>Annuler</button>
|
||||||
|
<button className="primary" form="form-new-emprunt" type="submit" disabled={addingEmprunt}>{addingEmprunt ? 'Création…' : 'Créer'}</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<form id="form-new-emprunt" onSubmit={e => runAddEmprunt(addEmprunt, e)} style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
{err && <div className="error">{err}</div>}
|
||||||
|
<EmpruntFormFields state={newEmprunt} setter={setNewEmprunt} investisseurs={investisseurs} institutions={institutions} />
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* ── Modal édition ── */}
|
||||||
|
<Modal
|
||||||
|
open={!!editEmprunt}
|
||||||
|
title="Modifier l'emprunt"
|
||||||
|
onClose={() => { setEditEmprunt(null); setErr(null); }}
|
||||||
|
busy={savingEdit}
|
||||||
|
footer={
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', width: '100%' }}>
|
||||||
|
<button className="danger" type="button" disabled={savingEdit} onClick={() => { setEditEmprunt(null); del(emprunts.find(e => e.id === editEmprunt?.id) ?? editEmprunt); }}>
|
||||||
|
Supprimer
|
||||||
|
</button>
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<button type="button" disabled={savingEdit} onClick={() => { setEditEmprunt(null); setErr(null); }}>Annuler</button>
|
||||||
|
<button className="primary" form="form-edit-emprunt" type="submit" disabled={savingEdit}>{savingEdit ? 'Enregistrement…' : 'Enregistrer'}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<form id="form-edit-emprunt" onSubmit={e => runSaveEdit(saveEdit, e)} style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
{err && <div className="error">{err}</div>}
|
||||||
|
<EmpruntFormFields state={editEmprunt} setter={setEditEmprunt} investisseurs={investisseurs} institutions={institutions} />
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* ── ConfirmModal suppression ── */}
|
||||||
|
<ConfirmModal
|
||||||
|
open={!!confirmDel}
|
||||||
|
message={confirmDel?.message}
|
||||||
|
onConfirm={confirmDel?.onConfirm}
|
||||||
|
onCancel={() => setConfirmDel(null)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||