diff --git a/backend/src/db/index.js b/backend/src/db/index.js
index 639bb13..e20c749 100644
--- a/backend/src/db/index.js
+++ b/backend/src/db/index.js
@@ -22,11 +22,11 @@ const PENDING_RESTORE = DB_PATH + '.pending-restore';
// générées côté dev (api_keys, y compris les clés internes auto-provisionnées
// pour l'assistant IA), écrase la configuration des fournisseurs IA propres
// à dev (ai_providers/ai_config) par celle de la source restaurée, et
-// écraserait de la même façon l'étiquette d'environnement de cette instance
-// (parametres_environnement — Admin > Général, cf. 05/09/26) par celle de la
-// source restaurée. Ordre important : ai_config référence ai_providers (FK
-// provider_defaut_id).
-const ENV_LOCAL_TABLES = ['api_keys', 'ai_providers', 'ai_config', 'parametres_environnement'];
+// écraserait de la même façon l'étiquette d'environnement (parametres_environnement)
+// et le thème de couleur (parametres_theme — Admin > Général, cf. 05/09/26)
+// de cette instance par ceux de la source restaurée. Ordre important :
+// ai_config référence ai_providers (FK provider_defaut_id).
+const ENV_LOCAL_TABLES = ['api_keys', 'ai_providers', 'ai_config', 'parametres_environnement', 'parametres_theme'];
let pendingRestorePreserve = null;
if (fs.existsSync(PENDING_RESTORE) && fs.existsSync(DB_PATH)) {
try {
@@ -42,7 +42,7 @@ if (fs.existsSync(PENDING_RESTORE) && fs.existsSync(DB_PATH)) {
}
oldDb.close();
} catch (e) {
- console.error('[restore] Échec de la sauvegarde des tables locales (api_keys/ai_providers/ai_config/parametres_environnement) avant restauration :', e.message);
+ console.error('[restore] Échec de la sauvegarde des tables locales (api_keys/ai_providers/ai_config/parametres_environnement/parametres_theme) avant restauration :', e.message);
pendingRestorePreserve = null;
}
}
@@ -1847,6 +1847,72 @@ db.exec(`
`);
db.exec(`INSERT OR IGNORE INTO parametres_environnement (id) VALUES (1)`);
+// ── Migration : thème de couleur de l'interface (Admin > Général) ───────────
+// Demande Olivier 05/09/26 : teinte de l'interface (menus, boutons — distincte
+// du mode clair/sombre existant, cf. ThemeContext.jsx) choisie parmi une
+// liste de préréglages (frontend/src/styles.css, blocs [data-color-theme]),
+// appliquée à TOUS les utilisateurs (contrairement au mode clair/sombre, une
+// préférence par utilisateur). But d'Olivier : garder Production dans le
+// bleu par défaut mais donner aux autres environnements une couleur
+// différente, reconnaissable au premier coup d'œil. Table dédiée (même
+// raisonnement que parametres_environnement ci-dessus) et ajoutée à
+// ENV_LOCAL_TABLES : ne doit jamais être réécrite par une restauration.
+//
+// 05/09/26 (suite, même jour) : les 5 thèmes initiaux (bleu/vert/violet/
+// orange/bordeaux, couleurs choisies librement) sont remplacés par 10,
+// alignés sur les 10 palettes de graphiques de Settings > Apparence
+// (cf. project_chart_colors.md) — chaque thème reprend la TEINTE de la
+// couleur "Intérêts" de la palette correspondante (même méthode de rotation
+// que les 5 thèmes initiaux : S/L de chaque jeton CSS inchangés, seule la
+// teinte tourne). SQLite ne permet pas d'altérer un CHECK constraint : la
+// table doit être recréée, même stratégie que les migrations perte_definitive/
+// prolongation sur investissements plus loin dans ce fichier. Valeur existante
+// migrée : 'violet' reste 'violet' (même nom, teinte recalculée depuis la
+// palette) ; tout le reste (bleu/vert/orange/bordeaux) retombe sur 'classique'
+// — décision explicite d'Olivier pour son propre 'bordeaux' actif, généralisée
+// à toute valeur historique sans successeur direct parmi les 10 palettes.
+{
+ const NEW_COLOR_THEMES = ['classique', 'indigo_teal', 'nuit', 'nature', 'coucher_de_soleil', 'violet', 'frais', 'contraste', 'pastel', 'moderne'];
+ const schemaTheme = db.prepare(
+ "SELECT sql FROM sqlite_master WHERE type='table' AND name='parametres_theme'"
+ ).get()?.sql ?? '';
+
+ if (schemaTheme && !schemaTheme.includes("'classique'")) {
+ const oldValue = db.prepare('SELECT color_theme FROM parametres_theme WHERE id = 1').get()?.color_theme;
+ const migratedValue = NEW_COLOR_THEMES.includes(oldValue) ? oldValue : 'classique';
+ const tempName = '__repair_parametres_theme';
+
+ db.exec('PRAGMA foreign_keys = OFF');
+ db.exec(`DROP TABLE IF EXISTS "${tempName}"`);
+ db.exec(`
+ CREATE TABLE "${tempName}" (
+ id INTEGER PRIMARY KEY CHECK (id = 1),
+ color_theme TEXT NOT NULL DEFAULT 'classique'
+ CHECK (color_theme IN ('classique', 'indigo_teal', 'nuit', 'nature', 'coucher_de_soleil', 'violet', 'frais', 'contraste', 'pastel', 'moderne')),
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
+ )
+ `);
+ db.prepare(`INSERT INTO "${tempName}" (id, color_theme) VALUES (1, ?)`).run(migratedValue);
+ db.exec('DROP TABLE parametres_theme');
+ db.exec('PRAGMA legacy_alter_table = ON');
+ db.exec(`ALTER TABLE "${tempName}" RENAME TO parametres_theme`);
+ db.exec('PRAGMA legacy_alter_table = OFF');
+ db.exec('PRAGMA foreign_keys = ON');
+
+ console.log(`[DB] parametres_theme : migré vers les 10 thèmes basés sur les palettes de graphiques (valeur existante '${oldValue}' → '${migratedValue}')`);
+ } else {
+ db.exec(`
+ CREATE TABLE IF NOT EXISTS parametres_theme (
+ id INTEGER PRIMARY KEY CHECK (id = 1),
+ color_theme TEXT NOT NULL DEFAULT 'classique'
+ CHECK (color_theme IN ('classique', 'indigo_teal', 'nuit', 'nature', 'coucher_de_soleil', 'violet', 'frais', 'contraste', 'pastel', 'moderne')),
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
+ )
+ `);
+ }
+}
+db.exec(`INSERT OR IGNORE INTO parametres_theme (id) VALUES (1)`);
+
// ── Migration : email_verified sur users ─────────────────────────────────────
{
const userCols = db.prepare('PRAGMA table_info(users)').all().map(c => c.name);
@@ -2881,7 +2947,7 @@ if (pendingRestorePreserve) {
}
});
restoreEnvLocal();
- console.log('[restore] Tables locales (api_keys/ai_providers/ai_config/parametres_environnement) préservées et réappliquées après restauration.');
+ console.log('[restore] Tables locales (api_keys/ai_providers/ai_config/parametres_environnement/parametres_theme) préservées et réappliquées après restauration.');
} catch (e) {
console.error('[restore] Échec de la réapplication des tables locales préservées :', e.message);
}
diff --git a/backend/src/routes/general.js b/backend/src/routes/general.js
index d019496..84de970 100644
--- a/backend/src/routes/general.js
+++ b/backend/src/routes/general.js
@@ -27,9 +27,11 @@ function ensureRow() {
// colonne applique automatiquement son DEFAULT (5 Go / 500 documents,
// cf. migration dans db/index.js) quand elle est omise d'un INSERT.
}
- // parametres_environnement : normalement déjà créée avec sa ligne par défaut
- // par la migration dans db/index.js (INSERT OR IGNORE) — filet de sécurité ici.
+ // parametres_environnement / parametres_theme : normalement déjà créées avec
+ // leur ligne par défaut par la migration dans db/index.js (INSERT OR IGNORE)
+ // — filet de sécurité ici.
db.prepare('INSERT OR IGNORE INTO parametres_environnement (id) VALUES (1)').run();
+ db.prepare('INSERT OR IGNORE INTO parametres_theme (id) VALUES (1)').run();
}
// 1 Go = 1024^3 octets (convention déjà utilisée par fmtOctets côté frontend).
@@ -40,10 +42,12 @@ router.get('/', (_req, res, next) => {
ensureRow();
const row = db.prepare('SELECT app_name, app_url, mcp_url, allow_registration, min_password_length, documents_quota_bytes, documents_quota_count FROM smtp_config WHERE id = 1').get();
const envRow = db.prepare('SELECT environnement FROM parametres_environnement WHERE id = 1').get();
+ const themeRow = db.prepare('SELECT color_theme FROM parametres_theme WHERE id = 1').get();
res.json({
appName: row.app_name || 'Crowdlending Tracker',
appUrl: row.app_url || '',
environnement: envRow?.environnement || 'developpement',
+ colorTheme: themeRow?.color_theme || 'classique',
mcpUrl: row.mcp_url || '',
allowRegistration: row.allow_registration !== 0,
minPasswordLength: row.min_password_length || 8,
@@ -57,6 +61,7 @@ const PatchSchema = z.object({
appName: z.string().min(1).max(100).optional(),
appUrl: z.string().max(500).optional(),
environnement: z.enum(['production', 'qualification', 'developpement']).optional(),
+ colorTheme: z.enum(['classique', 'indigo_teal', 'nuit', 'nature', 'coucher_de_soleil', 'violet', 'frais', 'contraste', 'pastel', 'moderne']).optional(),
mcpUrl: z.string().max(500).optional(),
allowRegistration: z.boolean().optional(),
minPasswordLength: z.number().int().min(6).max(64).optional(),
@@ -93,6 +98,9 @@ router.patch('/', (req, res, next) => {
if (body.environnement !== undefined) {
db.prepare('UPDATE parametres_environnement SET environnement = ? WHERE id = 1').run(body.environnement);
}
+ if (body.colorTheme !== undefined) {
+ db.prepare('UPDATE parametres_theme SET color_theme = ? WHERE id = 1').run(body.colorTheme);
+ }
res.json({ ok: true });
} catch (e) { next(e); }
diff --git a/backend/src/routes/preferences.js b/backend/src/routes/preferences.js
index d03aa89..d7f9b3f 100644
--- a/backend/src/routes/preferences.js
+++ b/backend/src/routes/preferences.js
@@ -10,7 +10,18 @@ const ALLOWED_KEYS = new Set([
'chart_cashback',
'pfo_assujetti',
'objectifs_actifs',
- // Extensible : ajouter ici les futures prefs (theme, font_scale, langue, devise, display_mode…)
+ 'color_theme',
+ // Extensible : ajouter ici les futures prefs (font_scale, langue, devise, display_mode…)
+]);
+
+// Thème de couleur (Admin > Général, cf. project_theme_interface.md) — même
+// énumération que la colonne parametres_theme.color_theme côté admin.
+// Contrairement aux autres clés de ALLOWED_KEYS, la valeur est validée ici
+// (pas seulement la clé) car une valeur invalide casserait silencieusement
+// le rendu CSS ([data-color-theme] côté frontend n'a pas de fallback).
+const COLOR_THEME_VALUES = new Set([
+ 'classique', 'indigo_teal', 'nuit', 'nature', 'coucher_de_soleil',
+ 'violet', 'frais', 'contraste', 'pastel', 'moderne',
]);
/**
@@ -51,6 +62,10 @@ router.patch('/', (req, res) => {
return res.status(400).json({ error: `Clés inconnues : ${unknown.join(', ')}` });
}
+ if ('color_theme' in payload && !COLOR_THEME_VALUES.has(payload.color_theme)) {
+ return res.status(400).json({ error: `Valeur invalide pour color_theme : ${payload.color_theme}` });
+ }
+
const persist = db.transaction(() => {
for (const [key, value] of Object.entries(payload)) {
upsert.run(req.user.id, key, String(value));
@@ -61,4 +76,21 @@ router.patch('/', (req, res) => {
res.json({ ok: true });
});
+/**
+ * DELETE /api/preferences/:key
+ * Supprime la préférence de l'utilisateur connecté pour une clé donnée —
+ * utile pour revenir à un comportement "par défaut" (ex. "Automatique"
+ * pour le thème de couleur, cf. Settings > Apparence) plutôt que de
+ * stocker une valeur sentinelle : une clé absente n'est simplement plus
+ * retournée par GET (le frontend retombe alors sur sa valeur par défaut).
+ */
+router.delete('/:key', (req, res) => {
+ const { key } = req.params;
+ if (!ALLOWED_KEYS.has(key)) {
+ return res.status(400).json({ error: `Clé inconnue : ${key}` });
+ }
+ db.prepare('DELETE FROM user_preferences WHERE user_id = ? AND key = ?').run(req.user.id, key);
+ res.json({ ok: true });
+});
+
export default router;
diff --git a/backend/src/server.js b/backend/src/server.js
index 041b0f5..e9aea79 100644
--- a/backend/src/server.js
+++ b/backend/src/server.js
@@ -96,18 +96,23 @@ app.get('/api/health', (_, res) => res.json({ ok: true, ts: new Date().toISOStri
// Informations publiques de la plateforme (utilisées sur la page de login)
app.get('/api/app-info', (_, res) => {
try {
- const cfg = getSmtpConfig();
- const icon = db.prepare(`SELECT filename FROM app_icons WHERE name = 'logo-app' LIMIT 1`).get();
- const row = db.prepare('SELECT allow_registration, min_password_length, mcp_url FROM smtp_config WHERE id = 1').get();
+ const cfg = getSmtpConfig();
+ const icon = db.prepare(`SELECT filename FROM app_icons WHERE name = 'logo-app' LIMIT 1`).get();
+ const row = db.prepare('SELECT allow_registration, min_password_length, mcp_url FROM smtp_config WHERE id = 1').get();
+ // Thème de couleur (Admin > Général) : appliqué à TOUS les utilisateurs,
+ // y compris sur la page de connexion (avant authentification) — d'où son
+ // exposition ici, comme allowRegistration/mcpUrl déjà publics.
+ const theme = db.prepare('SELECT color_theme FROM parametres_theme WHERE id = 1').get();
res.json({
appName: cfg.appName || 'Crowdlending Tracker',
iconUrl: icon ? `/api/icons-files/${icon.filename}` : null,
allowRegistration: row ? row.allow_registration !== 0 : true,
minPasswordLength: row ? (row.min_password_length || 8) : 8,
mcpUrl: row ? (row.mcp_url || '') : '',
+ colorTheme: theme?.color_theme || 'classique',
});
} catch {
- res.json({ appName: 'Crowdlending Tracker', iconUrl: null, allowRegistration: true, minPasswordLength: 8, mcpUrl: '' });
+ res.json({ appName: 'Crowdlending Tracker', iconUrl: null, allowRegistration: true, minPasswordLength: 8, mcpUrl: '', colorTheme: 'classique' });
}
});
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 69263a6..48ea7f2 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -56,6 +56,11 @@ function FraisOnly({ children }) {
export default function App() {
const [allowRegistration, setAllowRegistration] = useState(true);
+ // Note (06/09/26) : le thème de couleur (Admin > Général, ou choix
+ // personnel de l'utilisateur) n'est plus géré ici — voir UiContext.jsx
+ // (colorTheme / adminColorTheme), qui applique l'attribut data-color-theme
+ // sur et fonctionne aussi avant authentification (UiProvider
+ // enveloppe App dans main.jsx, y compris pour la page de connexion).
useEffect(() => {
fetch('/api/app-info').then(r => r.json()).then(d => {
if (d.allowRegistration === false) setAllowRegistration(false);
diff --git a/frontend/src/components/FraisCumulChart.jsx b/frontend/src/components/FraisCumulChart.jsx
index c78069b..6518514 100644
--- a/frontend/src/components/FraisCumulChart.jsx
+++ b/frontend/src/components/FraisCumulChart.jsx
@@ -10,10 +10,13 @@ import { useMemo, useState, useRef } from 'react';
* montant par frais).
*/
-const COLOR = '#e8547a';
-const BG = '#070c15';
-const GRID = 'rgba(255,255,255,0.055)';
-const LABEL = '#4a5568';
+/* Couleurs alignées sur le thème actif (Admin > Général, cf.
+ * project_theme_interface.md) plutôt que fixées en dur — même mécanisme
+ * que InvChart.jsx / InteretsDonutChart.jsx. */
+const COLOR = 'var(--primary)';
+const BG = 'var(--surface)';
+const GRID = 'var(--border)';
+const LABEL = 'var(--text-muted)';
const RANGES = ['1J', '7J', '1M', '3M', 'YTD', '1A', 'TOUT'];
const MOIS_COURT = ['jan.','fév.','mar.','avr.','mai','juin','juil.','août','sep.','oct.','nov.','déc.'];
@@ -247,7 +250,7 @@ export default function FraisCumulChart({ rows }) {
{/* Ligne zéro */}
{yZero && yZero > PAD.top && yZero < PAD.top + plotH && (
diff --git a/frontend/src/components/SoldeChart.jsx b/frontend/src/components/SoldeChart.jsx
index c6a17e8..8828f3b 100644
--- a/frontend/src/components/SoldeChart.jsx
+++ b/frontend/src/components/SoldeChart.jsx
@@ -1,10 +1,13 @@
import { useMemo, useState, useRef, useEffect } from 'react';
/* ── Constantes ─────────────────────────────────────────────── */
-const GOLD = '#4fa8e8'; // bleu ciel — accord avec le thème navy du site
-const BG = '#070c15';
-const GRID = 'rgba(255,255,255,0.055)';
-const LABEL = '#4a5568';
+/* Couleurs alignées sur le thème actif (Admin > Général, cf.
+ * project_theme_interface.md) plutôt que fixées en dur — même mécanisme
+ * que InvChart.jsx / InteretsDonutChart.jsx. */
+const GOLD = 'var(--primary)';
+const BG = 'var(--surface)';
+const GRID = 'var(--border)';
+const LABEL = 'var(--text-muted)';
const RANGES = ['1J', '7J', '1M', '3M', 'YTD', '1A', 'TOUT'];
@@ -254,7 +257,7 @@ export default function SoldeChart({ rows, range: rangeProp, onRangeChange }) {
{/* Ligne zéro */}
{yZero && yZero > PAD.top && yZero < PAD.top + plotH && (
+ Thème de couleur par défaut de l'interface, appliqué à tout utilisateur + n'ayant pas choisi son propre thème (Réglages > Apparence). +
+ +