Gestion des Thèmes
This commit is contained in:
1 parent
bab7a79b63
commit
be3c93897b
24 files changed
+1038
-138
No files matched your search
+73
-7
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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;
|
||||
@@ -99,15 +99,20 @@ app.get('/api/app-info', (_, res) => {
|
||||
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' });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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 <html> 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);
|
||||
|
||||
@@ -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 && (
|
||||
<line x1={PAD.left} y1={yZero} x2={W - PAD.right} y2={yZero}
|
||||
stroke="rgba(255,255,255,0.18)" strokeWidth="1" />
|
||||
stroke={GRID} strokeWidth="1" />
|
||||
)}
|
||||
|
||||
{/* Remplissage dégradé */}
|
||||
@@ -273,7 +276,7 @@ export default function FraisCumulChart({ rows }) {
|
||||
{hover && (
|
||||
<g>
|
||||
<line x1={hover.x} y1={PAD.top} x2={hover.x} y2={PAD.top + plotH}
|
||||
stroke="rgba(255,255,255,0.12)" strokeWidth="1" strokeDasharray="3 4" />
|
||||
stroke={GRID} strokeWidth="1" strokeDasharray="3 4" />
|
||||
<circle cx={hover.x} cy={hover.y} r="4.5"
|
||||
fill={COLOR} stroke={BG} strokeWidth="2" />
|
||||
</g>
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { useMemo, useState, useRef } from 'react';
|
||||
|
||||
/* ── Constantes ─────────────────────────────────────────────── */
|
||||
const COLOR = '#4fa8e8';
|
||||
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.'];
|
||||
@@ -237,7 +240,7 @@ export default function InteretsChart({ rows, netMode }) {
|
||||
{/* Ligne zéro */}
|
||||
{yZero && yZero > PAD.top && yZero < PAD.top + plotH && (
|
||||
<line x1={PAD.left} y1={yZero} x2={W - PAD.right} y2={yZero}
|
||||
stroke="rgba(255,255,255,0.18)" strokeWidth="1" />
|
||||
stroke={GRID} strokeWidth="1" />
|
||||
)}
|
||||
|
||||
{/* Remplissage dégradé */}
|
||||
@@ -263,7 +266,7 @@ export default function InteretsChart({ rows, netMode }) {
|
||||
{hover && (
|
||||
<g>
|
||||
<line x1={hover.x} y1={PAD.top} x2={hover.x} y2={PAD.top + plotH}
|
||||
stroke="rgba(255,255,255,0.12)" strokeWidth="1" strokeDasharray="3 4" />
|
||||
stroke={GRID} strokeWidth="1" strokeDasharray="3 4" />
|
||||
<circle cx={hover.x} cy={hover.y} r="4.5"
|
||||
fill={COLOR} stroke={BG} strokeWidth="2" />
|
||||
</g>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { api } from '../api.js';
|
||||
import { useInteretsChart } from '../context/InteretsChartContext.jsx';
|
||||
import { useTheme } from '../context/ThemeContext.jsx';
|
||||
|
||||
const ICONS_BASE = '/api/icons-files/';
|
||||
|
||||
const GRID = 'rgba(255,255,255,0.055)';
|
||||
const LABEL = '#4a5568';
|
||||
const LABEL_LIGHT = '#4a5568';
|
||||
const LABEL_DARK = 'rgba(255,255,255,.65)';
|
||||
const MOIS = ['Jan','Fév','Mar','Avr','Mai','Jun','Jul','Aoû','Sep','Oct','Nov','Déc'];
|
||||
|
||||
function fmtShort(v) {
|
||||
@@ -40,6 +42,8 @@ export default function InteretsMensuelsChart() {
|
||||
netMode,
|
||||
chartInterets, chartCapital, chartCashback,
|
||||
} = useInteretsChart();
|
||||
const { resolved } = useTheme();
|
||||
const LABEL = resolved === 'dark' ? LABEL_DARK : LABEL_LIGHT;
|
||||
|
||||
// État local uniquement
|
||||
const [hovered, setHovered] = useState(null);
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { useMemo, useState, useRef } from 'react';
|
||||
|
||||
/* ── Constantes ─────────────────────────────────────────────── */
|
||||
const BLUE = '#4fa8e8';
|
||||
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 InteretsDonutChart.jsx (attributs de présentation SVG fill/stroke
|
||||
* acceptant var(--...), supporté nativement par les navigateurs modernes). */
|
||||
const BLUE = '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.'];
|
||||
@@ -292,7 +296,7 @@ export default function InvChart({ rows, remboursements, reinvestissements, plat
|
||||
{/* Ligne zéro */}
|
||||
{yZero && yZero > PAD.top && yZero < PAD.top + plotH && (
|
||||
<line x1={PAD.left} y1={yZero} x2={W - PAD.right} y2={yZero}
|
||||
stroke="rgba(255,255,255,0.18)" strokeWidth="1" />
|
||||
stroke={GRID} strokeWidth="1" />
|
||||
)}
|
||||
|
||||
{/* Aire dégradée + courbe */}
|
||||
@@ -316,7 +320,7 @@ export default function InvChart({ rows, remboursements, reinvestissements, plat
|
||||
{hover && (
|
||||
<g>
|
||||
<line x1={hover.x} y1={PAD.top} x2={hover.x} y2={PAD.top + plotH}
|
||||
stroke="rgba(255,255,255,0.12)" strokeWidth="1" strokeDasharray="3 4" />
|
||||
stroke={GRID} strokeWidth="1" strokeDasharray="3 4" />
|
||||
<circle cx={hover.x} cy={hover.y} r="4.5"
|
||||
fill={BLUE} stroke={BG} strokeWidth="2" />
|
||||
</g>
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
/* Logo Crowdlending — carré aligné sur le thème d'interface actif
|
||||
* (Admin > Général, cf. project_theme_interface.md) : le fond suit
|
||||
* var(--primary), les 3 barres restent blanches à opacités variées
|
||||
* pour la profondeur, et la flèche (accent "croissance") garde un
|
||||
* vert dédié via var(--sidebar-accent) — stable quel que soit le
|
||||
* thème de couleur choisi (non surchargé par les 9 thèmes) — plutôt
|
||||
* que fixé en dur. Même mécanisme que InvChart.jsx /
|
||||
* InteretsDonutChart.jsx (attributs de présentation SVG fill
|
||||
* acceptant var(--...), supporté nativement). */
|
||||
export default function Logo({ size = 32 }) {
|
||||
return (
|
||||
<svg
|
||||
@@ -8,11 +17,11 @@ export default function Logo({ size = 32 }) {
|
||||
style={{ display: 'block', flexShrink: 0 }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect width="48" height="48" rx="10" fill="#1e3a8a" />
|
||||
<rect x="7" y="31" width="9" height="11" rx="2" fill="#93c5fd" />
|
||||
<rect x="20" y="21" width="9" height="21" rx="2" fill="#60a5fa" />
|
||||
<rect width="48" height="48" rx="10" fill="var(--primary)" />
|
||||
<rect x="7" y="31" width="9" height="11" rx="2" fill="white" fillOpacity="0.55" />
|
||||
<rect x="20" y="21" width="9" height="21" rx="2" fill="white" fillOpacity="0.78" />
|
||||
<rect x="33" y="11" width="9" height="31" rx="2" fill="white" />
|
||||
<polygon points="37.5,4 44,12 31,12" fill="#4ade80" />
|
||||
<polygon points="37.5,4 44,12 31,12" fill="var(--sidebar-accent)" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -28,6 +28,7 @@ export default function PageIcon({ name, size = 40, style }) {
|
||||
return (
|
||||
<img
|
||||
src={`${ICONS_BASE}${filename}`}
|
||||
className="app-lib-icon"
|
||||
width={size}
|
||||
height={size}
|
||||
alt=""
|
||||
@@ -37,7 +38,7 @@ export default function PageIcon({ name, size = 40, style }) {
|
||||
verticalAlign: 'middle',
|
||||
marginRight: 10,
|
||||
objectFit: 'contain',
|
||||
opacity: 0.85,
|
||||
opacity: 1,
|
||||
...style,
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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 && (
|
||||
<line x1={PAD.left} y1={yZero} x2={W - PAD.right} y2={yZero}
|
||||
stroke="rgba(255,255,255,0.18)" strokeWidth="1" />
|
||||
stroke={GRID} strokeWidth="1" />
|
||||
)}
|
||||
|
||||
{/* Remplissage dégradé */}
|
||||
@@ -280,7 +283,7 @@ export default function SoldeChart({ rows, range: rangeProp, onRangeChange }) {
|
||||
{hover && (
|
||||
<g>
|
||||
<line x1={hover.x} y1={PAD.top} x2={hover.x} y2={PAD.top + plotH}
|
||||
stroke="rgba(255,255,255,0.12)" strokeWidth="1" strokeDasharray="3 4" />
|
||||
stroke={GRID} strokeWidth="1" strokeDasharray="3 4" />
|
||||
<circle cx={hover.x} cy={hover.y} r="4.5"
|
||||
fill={GOLD} stroke={BG} strokeWidth="2" />
|
||||
</g>
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* ThemeSelect — sélecteur déroulant pour un choix unique avec pastille de
|
||||
* couleur (ex : thème de couleur de l'interface, Admin > Général).
|
||||
* Même schéma d'interaction que CategorySelect (bouton déclencheur +
|
||||
* dropdown, fermeture au clic extérieur / scroll / resize), en position
|
||||
* absolue (pas de portail fixed : utilisé dans une page classique, pas
|
||||
* dans une modale au overflow contraint).
|
||||
*
|
||||
* Props :
|
||||
* value : string — value sélectionnée
|
||||
* onChange : (value: string) => void
|
||||
* options : { value, label, swatch }[] — liste des choix
|
||||
*/
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
export default function ThemeSelect({ value, onChange, options = [] }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const wrapRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const close = (e) => {
|
||||
if (wrapRef.current?.contains(e.target)) return;
|
||||
setOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', close);
|
||||
return () => document.removeEventListener('mousedown', close);
|
||||
}, [open]);
|
||||
|
||||
const current = options.find(o => o.value === value) || options[0];
|
||||
|
||||
return (
|
||||
<div ref={wrapRef} className="theme-select-wrap">
|
||||
<button
|
||||
type="button"
|
||||
className={`theme-select-trigger${open ? ' open' : ''}`}
|
||||
onClick={() => setOpen(o => !o)}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
>
|
||||
<span className="theme-select-swatch" style={{ background: current?.swatch }} aria-hidden="true" />
|
||||
<span className="theme-select-label">{current?.label || 'Choisir…'}</span>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"
|
||||
style={{ flexShrink: 0, marginLeft: 'auto', transition: 'transform .15s', transform: open ? 'rotate(180deg)' : 'rotate(0deg)' }}
|
||||
aria-hidden="true">
|
||||
<path d="M6 9l6 6 6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="theme-select-dropdown" role="listbox">
|
||||
{options.map(({ value: v, label, swatch }) => {
|
||||
const active = v === value;
|
||||
return (
|
||||
<button
|
||||
key={v}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={active}
|
||||
className={`theme-select-item${active ? ' active' : ''}`}
|
||||
onClick={() => { onChange(v); setOpen(false); }}
|
||||
>
|
||||
<span className="theme-select-swatch" style={{ background: swatch }} aria-hidden="true" />
|
||||
<span className="theme-select-item-label">{label}</span>
|
||||
{active && (
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"
|
||||
style={{ marginLeft: 'auto', color: 'var(--accent)' }} aria-hidden="true">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -56,7 +56,7 @@ function UserAvatar({ user, size = 36 }) {
|
||||
return (
|
||||
<div style={{
|
||||
width: size, height: size, borderRadius: '50%',
|
||||
background: 'linear-gradient(135deg, #1e40af 0%, #1e3a8a 100%)',
|
||||
background: 'linear-gradient(135deg, var(--primary) 0%, var(--primary-hover) 100%)',
|
||||
border: '2px solid rgba(74,222,128,.5)',
|
||||
color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontWeight: 700, fontSize: Math.round(size * 0.38), flexShrink: 0,
|
||||
@@ -70,8 +70,8 @@ function UserAvatar({ user, size = 36 }) {
|
||||
function MemberAvatar({ member, size = 28 }) {
|
||||
const isEntreprise = member?.type === 'entreprise';
|
||||
const bg = isEntreprise
|
||||
? 'linear-gradient(135deg, #3730a3, #4338ca)'
|
||||
: 'linear-gradient(135deg, #1e3a8a, #1e40af)';
|
||||
? 'linear-gradient(135deg, var(--avatar-entreprise-start), var(--avatar-entreprise-end))'
|
||||
: 'linear-gradient(135deg, var(--primary-hover), var(--primary))';
|
||||
return (
|
||||
<div style={{
|
||||
width: size, height: size, borderRadius: '50%',
|
||||
@@ -89,7 +89,7 @@ function TeamBadge({ size = 28 }) {
|
||||
return (
|
||||
<div className="team-badge" style={{
|
||||
width: size, height: size, borderRadius: '50%',
|
||||
background: '#1e3a8a',
|
||||
background: 'var(--primary-hover)',
|
||||
border: '1.5px solid rgba(255,255,255,.25)',
|
||||
color: '#fff',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
import { createContext, useContext, useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { api } from '../api.js';
|
||||
import { THEME_TO_PALETTE_NAME } from '../utils/colorThemes.js';
|
||||
import { getSuggestedPalettes } from '../utils/chartPalettes.js';
|
||||
|
||||
/**
|
||||
* UI state that persists across sessions:
|
||||
* - sidebarCollapsed : sidebar visibility
|
||||
* - fontScale : 'large' (max/accessibilité) | 'medium' | 'compact'
|
||||
* - colorTheme : thème de couleur personnel (06/09/26) — un des 10
|
||||
* thèmes de colorThemes.js, ou null ("Automatique" :
|
||||
* suit le thème par défaut défini par l'administrateur
|
||||
* dans Admin > Général, cf. adminColorTheme). Stocké
|
||||
* en base (table user_preferences, clé color_theme)
|
||||
* pour suivre l'utilisateur d'un appareil à l'autre —
|
||||
* voir project_theme_interface.md.
|
||||
*/
|
||||
|
||||
const UiCtx = createContext(null);
|
||||
@@ -19,6 +28,8 @@ const KEY_CHART_CAPITAL = 'cl_chart_capital';
|
||||
const KEY_CHART_CASHBACK = 'cl_chart_cashback';
|
||||
const KEY_PFO_ASSUJETTI = 'cl_pfo_assujetti';
|
||||
const KEY_OBJECTIFS_ACTIFS = 'cl_objectifs_actifs';
|
||||
const KEY_COLOR_THEME = 'cl_color_theme';
|
||||
const KEY_ADMIN_COLOR_THEME = 'cl_admin_color_theme';
|
||||
|
||||
const FONT_SIZES = ['large', 'medium', 'compact'];
|
||||
const LANGUES = ['fr', 'en'];
|
||||
@@ -79,6 +90,37 @@ export function UiProvider({ children }) {
|
||||
return saved === null ? false : saved === '1';
|
||||
});
|
||||
|
||||
// Thème de couleur personnel (06/09/26) : null = "Automatique" (suit
|
||||
// adminColorTheme). Une valeur explicite ici prime toujours sur le thème
|
||||
// par défaut de l'administrateur.
|
||||
const [colorTheme, setColorThemeState] = useState(() =>
|
||||
localStorage.getItem(KEY_COLOR_THEME) || null
|
||||
);
|
||||
|
||||
// Thème par défaut défini par l'administrateur (Admin > Général), utilisé
|
||||
// en repli quand colorTheme est null. Mis en cache en localStorage pour
|
||||
// éviter un flash au chargement le temps que /api/app-info réponde.
|
||||
const [adminColorTheme, setAdminColorThemeState] = useState(() =>
|
||||
localStorage.getItem(KEY_ADMIN_COLOR_THEME) || 'classique'
|
||||
);
|
||||
|
||||
// ── Thème par défaut (admin) : chargé une fois au montage ───────
|
||||
// Endpoint public (pas d'auth requise) : reste valable sur la page de
|
||||
// connexion, comme dans App.jsx.
|
||||
useEffect(() => {
|
||||
fetch('/api/app-info').then(r => r.json()).then(d => {
|
||||
const t = d.colorTheme || 'classique';
|
||||
localStorage.setItem(KEY_ADMIN_COLOR_THEME, t);
|
||||
setAdminColorThemeState(t);
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
// ── Applique le thème de couleur effectif sur <html> ────────────
|
||||
// Un choix personnel (colorTheme) prime toujours sur le défaut admin.
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-color-theme', colorTheme || adminColorTheme);
|
||||
}, [colorTheme, adminColorTheme]);
|
||||
|
||||
// ── Sync DB → localStorage au montage ──────────────────────────
|
||||
// La DB fait foi : ses valeurs écrasent le localStorage si présentes.
|
||||
useEffect(() => {
|
||||
@@ -105,6 +147,10 @@ export function UiProvider({ children }) {
|
||||
localStorage.setItem(KEY_OBJECTIFS_ACTIFS, val ? '1' : '0');
|
||||
setObjectifsActifsState(val);
|
||||
}
|
||||
if (prefs.color_theme) {
|
||||
localStorage.setItem(KEY_COLOR_THEME, prefs.color_theme);
|
||||
setColorThemeState(prefs.color_theme);
|
||||
}
|
||||
}).catch(() => {
|
||||
// Silencieux : token absent au premier rendu, on garde le localStorage
|
||||
});
|
||||
@@ -163,6 +209,37 @@ export function UiProvider({ children }) {
|
||||
persistPref('chart_cashback', hex);
|
||||
}, [persistPref]);
|
||||
|
||||
// Adapte automatiquement la palette de couleurs de graphiques (section
|
||||
// ci-dessus) sur celle associée au thème donné (même logique que le
|
||||
// reverse-coupling admin de GeneralSection.jsx) — appelé uniquement lors
|
||||
// d'un changement explicite de thème, jamais lors de la resynchronisation
|
||||
// silencieuse au montage (sinon un choix de couleurs personnalisé serait
|
||||
// écrasé à chaque rechargement de page).
|
||||
const applyPaletteForTheme = useCallback((themeValue) => {
|
||||
const paletteName = THEME_TO_PALETTE_NAME[themeValue];
|
||||
if (!paletteName) return;
|
||||
const palette = getSuggestedPalettes().find(p => p.name === paletteName);
|
||||
if (!palette) return;
|
||||
setChartInterets(palette.interets);
|
||||
setChartCapital(palette.capital);
|
||||
setChartCashback(palette.cashback);
|
||||
}, [setChartInterets, setChartCapital, setChartCashback]);
|
||||
|
||||
// value = un des thèmes de colorThemes.js pour un choix personnel explicite,
|
||||
// ou null/undefined pour revenir à "Automatique" (suit le thème par défaut
|
||||
// de l'administrateur — cf. adminColorTheme).
|
||||
const setColorTheme = useCallback((value) => {
|
||||
setColorThemeState(value || null);
|
||||
if (value) {
|
||||
localStorage.setItem(KEY_COLOR_THEME, value);
|
||||
persistPref('color_theme', value);
|
||||
} else {
|
||||
localStorage.removeItem(KEY_COLOR_THEME);
|
||||
api.del('/preferences/color_theme').catch(() => {});
|
||||
}
|
||||
applyPaletteForTheme(value || adminColorTheme);
|
||||
}, [persistPref, applyPaletteForTheme, adminColorTheme]);
|
||||
|
||||
const setPfoAssujetti = useCallback((val) => {
|
||||
setPfoAssujettiState(val);
|
||||
localStorage.setItem(KEY_PFO_ASSUJETTI, val ? '1' : '0');
|
||||
@@ -176,7 +253,7 @@ export function UiProvider({ children }) {
|
||||
}, [persistPref]);
|
||||
|
||||
return (
|
||||
<UiCtx.Provider value={{ sidebarCollapsed, setSidebarCollapsed, toggleSidebar, fontScale, setFontScale, langue, setLangue, devise, setDevise, displayMode, setDisplayMode, chartInterets, setChartInterets, chartCapital, setChartCapital, chartCashback, setChartCashback, pfoAssujetti, setPfoAssujetti, objectifsActifs, setObjectifsActifs }}>
|
||||
<UiCtx.Provider value={{ sidebarCollapsed, setSidebarCollapsed, toggleSidebar, fontScale, setFontScale, langue, setLangue, devise, setDevise, displayMode, setDisplayMode, chartInterets, setChartInterets, chartCapital, setChartCapital, chartCashback, setChartCashback, pfoAssujetti, setPfoAssujetti, objectifsActifs, setObjectifsActifs, colorTheme, adminColorTheme, setColorTheme }}>
|
||||
{children}
|
||||
</UiCtx.Provider>
|
||||
);
|
||||
|
||||
@@ -48,12 +48,12 @@ function YearSelectorKpi() {
|
||||
onClick={() => setOpen(v => !v)}
|
||||
style={{
|
||||
height: '100%', boxSizing: 'border-box',
|
||||
background: 'linear-gradient(135deg, #7c3aed 0%, #4f46e5 100%)',
|
||||
background: 'linear-gradient(135deg, var(--primary) 0%, var(--primary-hover) 100%)',
|
||||
borderRadius: 10,
|
||||
padding: '16px 20px',
|
||||
boxShadow: open
|
||||
? '0 6px 28px rgba(109,40,217,0.45)'
|
||||
: '0 4px 20px rgba(109,40,217,0.30)',
|
||||
? '0 6px 28px color-mix(in srgb, var(--primary) 45%, transparent)'
|
||||
: '0 4px 20px color-mix(in srgb, var(--primary) 30%, transparent)',
|
||||
cursor: 'pointer',
|
||||
display: 'flex', flexDirection: 'column', justifyContent: 'space-between',
|
||||
userSelect: 'none',
|
||||
@@ -101,8 +101,8 @@ function YearSelectorKpi() {
|
||||
style={{
|
||||
padding: '10px 16px',
|
||||
cursor: 'pointer',
|
||||
background: isActive ? 'rgba(109,40,217,0.08)' : 'transparent',
|
||||
color: isActive ? '#7c3aed' : 'var(--text)',
|
||||
background: isActive ? 'color-mix(in srgb, var(--primary) 8%, transparent)' : 'transparent',
|
||||
color: isActive ? 'var(--primary)' : 'var(--text)',
|
||||
fontWeight: isActive ? 700 : 400,
|
||||
fontSize: 'var(--fs-sm)',
|
||||
borderBottom: i < options.length - 1 ? '1px solid var(--border)' : 'none',
|
||||
@@ -115,7 +115,7 @@ function YearSelectorKpi() {
|
||||
<span>{opt.label}</span>
|
||||
{isActive && (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
|
||||
stroke="#7c3aed" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
stroke="var(--primary)" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
</svg>
|
||||
)}
|
||||
|
||||
@@ -9,8 +9,8 @@ import ConfirmModal from '../components/ConfirmModal.jsx';
|
||||
function MemberAvatar({ membre, size = 40 }) {
|
||||
const initials = memberInitials(membre);
|
||||
const bg = membre.type === 'entreprise'
|
||||
? 'linear-gradient(135deg, #4f46e5 0%, #3730a3 100%)'
|
||||
: 'linear-gradient(135deg, #1e40af 0%, #1e3a8a 100%)';
|
||||
? 'linear-gradient(135deg, var(--avatar-entreprise-start) 0%, var(--avatar-entreprise-end) 100%)'
|
||||
: 'linear-gradient(135deg, var(--primary) 0%, var(--primary-hover) 100%)';
|
||||
return (
|
||||
<div style={{
|
||||
width: size, height: size, borderRadius: '50%',
|
||||
|
||||
@@ -69,9 +69,11 @@ function PlatSelector({ platOptions, selectedPlatName, onSelect }) {
|
||||
<div
|
||||
onClick={() => setOpen(v => !v)}
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #7c3aed 0%, #4f46e5 100%)',
|
||||
background: 'linear-gradient(135deg, var(--primary) 0%, var(--primary-hover) 100%)',
|
||||
borderRadius: 10, padding: '12px 16px',
|
||||
boxShadow: open ? '0 6px 28px rgba(109,40,217,0.45)' : '0 4px 20px rgba(109,40,217,0.30)',
|
||||
boxShadow: open
|
||||
? '0 6px 28px color-mix(in srgb, var(--primary) 45%, transparent)'
|
||||
: '0 4px 20px color-mix(in srgb, var(--primary) 30%, transparent)',
|
||||
cursor: 'pointer', userSelect: 'none', transition: 'box-shadow .15s',
|
||||
}}
|
||||
>
|
||||
@@ -89,7 +91,7 @@ function PlatSelector({ platOptions, selectedPlatName, onSelect }) {
|
||||
{selected?.icon_filename && (
|
||||
<img src={`${LOGOS_BASE}${selected.icon_filename}`}
|
||||
width={28} height={28}
|
||||
style={{ objectFit: 'contain', borderRadius: 4, background: 'rgba(255,255,255,0.12)', padding: 2, flexShrink: 0 }}
|
||||
style={{ objectFit: 'contain', borderRadius: 4, background: '#fff', padding: 2, flexShrink: 0 }}
|
||||
alt="" />
|
||||
)}
|
||||
<span style={{ color: '#fff', fontSize: '1.1rem', fontWeight: 700, lineHeight: 1.2 }}>
|
||||
@@ -156,9 +158,11 @@ function DetenteurSelector({ detenteurOptions, selectedId, onSelect }) {
|
||||
<div
|
||||
onClick={() => setOpen(v => !v)}
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #0d9488 0%, #0891b2 100%)',
|
||||
background: 'linear-gradient(135deg, var(--primary) 0%, var(--primary-hover) 100%)',
|
||||
borderRadius: 10, padding: '12px 16px',
|
||||
boxShadow: open ? '0 6px 22px rgba(13,148,136,0.45)' : '0 4px 16px rgba(13,148,136,0.3)',
|
||||
boxShadow: open
|
||||
? '0 6px 22px color-mix(in srgb, var(--primary) 45%, transparent)'
|
||||
: '0 4px 16px color-mix(in srgb, var(--primary) 30%, transparent)',
|
||||
cursor: 'pointer', userSelect: 'none', transition: 'box-shadow .15s',
|
||||
}}
|
||||
>
|
||||
@@ -230,9 +234,11 @@ function AnneeSelector({ availableYears, selectedYear, onSelect }) {
|
||||
<div
|
||||
onClick={() => setOpen(v => !v)}
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #7c3aed 0%, #4f46e5 100%)',
|
||||
background: 'linear-gradient(135deg, var(--primary) 0%, var(--primary-hover) 100%)',
|
||||
borderRadius: 10, padding: '12px 16px',
|
||||
boxShadow: open ? '0 6px 28px rgba(109,40,217,0.45)' : '0 4px 20px rgba(109,40,217,0.30)',
|
||||
boxShadow: open
|
||||
? '0 6px 28px color-mix(in srgb, var(--primary) 45%, transparent)'
|
||||
: '0 4px 20px color-mix(in srgb, var(--primary) 30%, transparent)',
|
||||
cursor: 'pointer', userSelect: 'none', transition: 'box-shadow .15s',
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -32,12 +32,12 @@ function YearSelector({ annee, setAnnee, availableYears }) {
|
||||
onClick={() => setOpen(v => !v)}
|
||||
style={{
|
||||
height: '100%', boxSizing: 'border-box',
|
||||
background: 'linear-gradient(135deg, #7c3aed 0%, #4f46e5 100%)',
|
||||
background: 'linear-gradient(135deg, var(--primary) 0%, var(--primary-hover) 100%)',
|
||||
borderRadius: 10,
|
||||
padding: '12px 16px',
|
||||
boxShadow: open
|
||||
? '0 6px 28px rgba(109,40,217,0.45)'
|
||||
: '0 4px 20px rgba(109,40,217,0.30)',
|
||||
? '0 6px 28px color-mix(in srgb, var(--primary) 45%, transparent)'
|
||||
: '0 4px 20px color-mix(in srgb, var(--primary) 30%, transparent)',
|
||||
cursor: 'pointer',
|
||||
display: 'flex', flexDirection: 'column', justifyContent: 'space-between',
|
||||
userSelect: 'none',
|
||||
@@ -76,8 +76,8 @@ function YearSelector({ annee, setAnnee, availableYears }) {
|
||||
onClick={() => { setAnnee(String(yr)); setOpen(false); }}
|
||||
style={{
|
||||
padding: '10px 16px', cursor: 'pointer',
|
||||
background: isActive ? 'rgba(109,40,217,0.08)' : 'transparent',
|
||||
color: isActive ? '#7c3aed' : 'var(--text)',
|
||||
background: isActive ? 'color-mix(in srgb, var(--primary) 8%, transparent)' : 'transparent',
|
||||
color: isActive ? 'var(--primary)' : 'var(--text)',
|
||||
fontWeight: isActive ? 700 : 400,
|
||||
fontSize: 'var(--fs-sm)',
|
||||
borderBottom: i < availableYears.length - 1 ? '1px solid var(--border)' : 'none',
|
||||
@@ -90,7 +90,7 @@ function YearSelector({ annee, setAnnee, availableYears }) {
|
||||
<span>{yr}</span>
|
||||
{isActive && (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
|
||||
stroke="#7c3aed" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
stroke="var(--primary)" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
</svg>
|
||||
)}
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { api } from '../../api.js';
|
||||
import { withDevOverrides } from '../../utils/devOverrides.js';
|
||||
import { getSuggestedPalettes } from '../../utils/chartPalettes.js';
|
||||
import { COLOR_THEMES, THEME_TO_PALETTE_NAME } from '../../utils/colorThemes.js';
|
||||
import { useUi } from '../../context/UiContext.jsx';
|
||||
import ThemeSelect from '../../components/ThemeSelect.jsx';
|
||||
|
||||
function SettingRow({ label, description, children }) {
|
||||
return (
|
||||
@@ -36,6 +40,7 @@ const DEFAULT = {
|
||||
appName: 'Crowdlending Tracker',
|
||||
appUrl: '',
|
||||
environnement: 'developpement',
|
||||
colorTheme: 'classique',
|
||||
mcpUrl: '',
|
||||
allowRegistration: true,
|
||||
minPasswordLength: 8,
|
||||
@@ -50,6 +55,7 @@ const ENVIRONNEMENTS = [
|
||||
];
|
||||
|
||||
export default function GeneralSection() {
|
||||
const { setChartInterets, setChartCapital, setChartCashback } = useUi();
|
||||
const [form, setForm] = useState(DEFAULT);
|
||||
const [saved, setSaved] = useState(null); // 'ok' | 'err' | null
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -66,6 +72,7 @@ export default function GeneralSection() {
|
||||
appName: dd.appName || 'Crowdlending Tracker',
|
||||
appUrl: dd.appUrl || '',
|
||||
environnement: dd.environnement || 'developpement',
|
||||
colorTheme: dd.colorTheme || 'classique',
|
||||
mcpUrl: dd.mcpUrl || '',
|
||||
allowRegistration: dd.allowRegistration !== false,
|
||||
minPasswordLength: dd.minPasswordLength || 8,
|
||||
@@ -86,12 +93,29 @@ export default function GeneralSection() {
|
||||
appName: form.appName.trim(),
|
||||
appUrl: form.appUrl.trim(),
|
||||
environnement: form.environnement,
|
||||
colorTheme: form.colorTheme,
|
||||
mcpUrl: form.mcpUrl.trim(),
|
||||
allowRegistration: form.allowRegistration,
|
||||
minPasswordLength: form.minPasswordLength,
|
||||
documentsQuotaGo: form.documentsQuotaGo,
|
||||
documentsQuotaCount: form.documentsQuotaCount,
|
||||
});
|
||||
// Applique immédiatement la nouvelle teinte à cette session (sans
|
||||
// attendre un rechargement) — mêmes attribut/valeurs que App.jsx au
|
||||
// boot ('classique' = no-op, aucun bloc CSS [data-color-theme="classique"]).
|
||||
document.documentElement.setAttribute('data-color-theme', form.colorTheme);
|
||||
// Adapte automatiquement, sur ce compte, la palette de couleurs de
|
||||
// graphiques (Settings > Apparence) sur celle du thème qui vient
|
||||
// d'être choisi (même nom) — cf. project_theme_interface.md.
|
||||
const paletteName = THEME_TO_PALETTE_NAME[form.colorTheme];
|
||||
if (paletteName) {
|
||||
const palette = getSuggestedPalettes().find(p => p.name === paletteName);
|
||||
if (palette) {
|
||||
setChartInterets(palette.interets);
|
||||
setChartCapital(palette.capital);
|
||||
setChartCashback(palette.cashback);
|
||||
}
|
||||
}
|
||||
setSaved('ok');
|
||||
setTimeout(() => setSaved(null), 3000);
|
||||
} catch {
|
||||
@@ -165,6 +189,28 @@ export default function GeneralSection() {
|
||||
</SettingRow>
|
||||
</div>
|
||||
|
||||
{/* Apparence */}
|
||||
<div className="card" style={{ marginBottom: 24 }}>
|
||||
<h3 style={{ margin: '0 0 4px', fontSize: 14, fontWeight: 600, color: 'var(--text)' }}>Apparence</h3>
|
||||
<p style={{ margin: '0 0 20px', fontSize: 13, color: 'var(--text-muted)' }}>
|
||||
Thème de couleur par défaut de l'interface, appliqué à tout utilisateur
|
||||
n'ayant pas choisi son propre thème (Réglages > Apparence).
|
||||
</p>
|
||||
|
||||
<SettingRow
|
||||
label="Thème de couleur"
|
||||
description="Teinte des menus et boutons appliquée par défaut à tout compte n'ayant pas défini son propre thème dans Réglages > Apparence (chacun peut y choisir une couleur différente, stockée sur son compte). Utile pour repérer visuellement cet environnement au premier coup d'œil, par exemple en gardant Production dans le bleu par défaut et en donnant une autre couleur à Qualification/Développement. Conservé tel quel lors d'une restauration de base copiée depuis un autre environnement (comme Environnement ci-dessus)."
|
||||
>
|
||||
<div style={{ width: 260 }}>
|
||||
<ThemeSelect
|
||||
value={form.colorTheme}
|
||||
onChange={(v) => set('colorTheme', v)}
|
||||
options={COLOR_THEMES}
|
||||
/>
|
||||
</div>
|
||||
</SettingRow>
|
||||
</div>
|
||||
|
||||
{/* Accès */}
|
||||
<div className="card" style={{ marginBottom: 24 }}>
|
||||
<h3 style={{ margin: '0 0 4px', fontSize: 14, fontWeight: 600, color: 'var(--text)' }}>Accès</h3>
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useTheme } from '../../context/ThemeContext.jsx';
|
||||
import { useUi } from '../../context/UiContext.jsx';
|
||||
import { api } from '../../api.js';
|
||||
import { getSuggestedPalettes } from '../../utils/chartPalettes.js';
|
||||
import { COLOR_THEMES, colorThemeSwatch } from '../../utils/colorThemes.js';
|
||||
import ThemeSelect from '../../components/ThemeSelect.jsx';
|
||||
|
||||
|
||||
/* ── Apparence — données ─────────────────────────────────────── */
|
||||
@@ -109,7 +112,8 @@ function isLightColor(hex) {
|
||||
return (r * 299 + g * 587 + b * 114) / 1000 > 155;
|
||||
}
|
||||
|
||||
/* Retrouve la famille et la teinte d'un hex donné */
|
||||
/* Retrouve la famille et la teinte d'un hex donné (correspondance EXACTE
|
||||
* à une nuance MD_PALETTE) */
|
||||
function findInPalette(hex) {
|
||||
const h = hex.toLowerCase();
|
||||
for (const [family, { shades }] of Object.entries(MD_PALETTE)) {
|
||||
@@ -120,6 +124,51 @@ function findInPalette(hex) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/* Teinte (0-360) d'un hex, sans dépendance externe */
|
||||
function hexHue(hex) {
|
||||
const r = parseInt(hex.slice(1, 3), 16) / 255;
|
||||
const g = parseInt(hex.slice(3, 5), 16) / 255;
|
||||
const b = parseInt(hex.slice(5, 7), 16) / 255;
|
||||
const max = Math.max(r, g, b), min = Math.min(r, g, b);
|
||||
if (max === min) return 0;
|
||||
const d = max - min;
|
||||
let h;
|
||||
if (max === r) h = ((g - b) / d + (g < b ? 6 : 0));
|
||||
else if (max === g) h = (b - r) / d + 2;
|
||||
else h = (r - g) / d + 4;
|
||||
return h * 60;
|
||||
}
|
||||
|
||||
/* Famille MD_PALETTE la plus proche en teinte pour un hex qui ne
|
||||
* correspond à AUCUNE nuance exacte (ex. couleur de thème dynamique pour
|
||||
* "Classique", ou couleurs des 9 autres palettes suggérées, calculées par
|
||||
* rotation HSL et non alignées sur les nuances MD_PALETTE) — évite
|
||||
* d'afficher une famille sans rapport avec la couleur réelle (bug
|
||||
* rencontré le 05/09/26 : famille "Vert" restée affichée, avec son point
|
||||
* indicateur vert, après avoir choisi la palette "Coucher de soleil"
|
||||
* (orange), car l'ancien code laissait `selectedFamily` inchangé — donc
|
||||
* périmée — dès que `findInPalette` ne trouvait aucune correspondance
|
||||
* exacte). Ne présuppose jamais une correspondance exacte de nuance : le
|
||||
* point indicateur affiche toujours la vraie couleur (`value`), jamais
|
||||
* `familyData.base`. */
|
||||
function findNearestFamily(hex) {
|
||||
const targetHue = hexHue(hex);
|
||||
let best = 'Bleu', bestDist = Infinity;
|
||||
for (const [family, { base }] of Object.entries(MD_PALETTE)) {
|
||||
// "Gris" est achromatique (R=G=B) : sa teinte n'a mathématiquement pas
|
||||
// de sens (0 par convention), ce qui le fait à tort "gagner" pour les
|
||||
// couleurs proches du rouge (teinte elle aussi proche de 0). L'exclure
|
||||
// du calcul de plus proche teinte.
|
||||
if (family === 'Gris') continue;
|
||||
const dist = Math.min(
|
||||
Math.abs(hexHue(base) - targetHue),
|
||||
360 - Math.abs(hexHue(base) - targetHue)
|
||||
);
|
||||
if (dist < bestDist) { bestDist = dist; best = family; }
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/* Mélange une couleur hex avec du blanc (factor 0=original, 1=blanc) */
|
||||
function lightenColor(hex, factor = 0.72) {
|
||||
const r = parseInt(hex.slice(1,3),16);
|
||||
@@ -131,21 +180,7 @@ function lightenColor(hex, factor = 0.72) {
|
||||
return '#' + [lr,lg,lb].map(v => v.toString(16).padStart(2,'0')).join('');
|
||||
}
|
||||
|
||||
/* ── Palettes suggérées ──────────────────────────────────────── */
|
||||
const SUGGESTED_PALETTES = [
|
||||
{ name: 'Classique', interets: '#2196f3', capital: '#4caf50', cashback: '#ffc107' },
|
||||
{ name: 'Indigo & Teal', interets: '#3949ab', capital: '#009688', cashback: '#fb8c00' },
|
||||
{ name: 'Nuit', interets: '#5c6bc0', capital: '#4dd0e1', cashback: '#ffca28' },
|
||||
{ name: 'Nature', interets: '#43a047', capital: '#039be5', cashback: '#ff9800' },
|
||||
{ name: 'Coucher de soleil', interets: '#ff5722', capital: '#1976d2', cashback: '#ffa000' },
|
||||
{ name: 'Violet', interets: '#7e57c2', capital: '#26a69a', cashback: '#ffb300' },
|
||||
{ name: 'Frais', interets: '#00acc1', capital: '#7cb342', cashback: '#ec407a' },
|
||||
{ name: 'Contraste', interets: '#e53935', capital: '#1e88e5', cashback: '#43a047' },
|
||||
{ name: 'Pastel', interets: '#29b6f6', capital: '#66bb6a', cashback: '#ffb74d' },
|
||||
{ name: 'Moderne', interets: '#9c27b0', capital: '#00bcd4', cashback: '#ff9800' },
|
||||
];
|
||||
|
||||
function PaletteSelector({ interets, capital, cashback, onSelect }) {
|
||||
function PaletteSelector({ interets, capital, cashback, onSelect, palettes }) {
|
||||
const isActive = (p) =>
|
||||
p.interets === interets && p.capital === capital && p.cashback === cashback;
|
||||
|
||||
@@ -155,7 +190,7 @@ function PaletteSelector({ interets, capital, cashback, onSelect }) {
|
||||
Palettes suggérées
|
||||
</p>
|
||||
<div className="palette-grid">
|
||||
{SUGGESTED_PALETTES.map((p) => (
|
||||
{palettes.map((p) => (
|
||||
<button
|
||||
key={p.name}
|
||||
type="button"
|
||||
@@ -178,12 +213,15 @@ function PaletteSelector({ interets, capital, cashback, onSelect }) {
|
||||
|
||||
function ChartColorPicker({ value, onChange }) {
|
||||
const found = findInPalette(value);
|
||||
const [selectedFamily, setSelectedFamily] = useState(found?.family || 'Bleu');
|
||||
const [selectedFamily, setSelectedFamily] = useState(found?.family || findNearestFamily(value));
|
||||
|
||||
// Sync la famille quand value change depuis l'extérieur (sélection palette)
|
||||
// Sync la famille quand value change depuis l'extérieur (sélection
|
||||
// palette) : toujours retomber sur la famille la plus proche en teinte
|
||||
// à défaut de correspondance exacte, jamais laisser `selectedFamily`
|
||||
// périmé (cf. commentaire de findNearestFamily ci-dessus).
|
||||
useEffect(() => {
|
||||
const f = findInPalette(value);
|
||||
if (f) setSelectedFamily(f.family);
|
||||
setSelectedFamily(f ? f.family : findNearestFamily(value));
|
||||
}, [value]);
|
||||
|
||||
const familyData = MD_PALETTE[selectedFamily];
|
||||
@@ -203,7 +241,7 @@ function ChartColorPicker({ value, onChange }) {
|
||||
return (
|
||||
<div className="chart-color-picker">
|
||||
<div className="color-family-select-row">
|
||||
<span className="color-family-indicator" style={{ backgroundColor: familyData?.base || value }} />
|
||||
<span className="color-family-indicator" style={{ backgroundColor: value }} />
|
||||
<select value={selectedFamily} onChange={handleFamilyChange}>
|
||||
{Object.keys(MD_PALETTE).map(name => (
|
||||
<option key={name} value={name}>{name}</option>
|
||||
@@ -242,8 +280,15 @@ function ChartColorPicker({ value, onChange }) {
|
||||
|
||||
export default function AppearanceSection() {
|
||||
const { mode, setMode } = useTheme();
|
||||
const { fontScale, setFontScale, chartInterets, setChartInterets, chartCapital, setChartCapital, chartCashback, setChartCashback } = useUi();
|
||||
const { fontScale, setFontScale, chartInterets, setChartInterets, chartCapital, setChartCapital, chartCashback, setChartCashback, colorTheme, adminColorTheme, setColorTheme } = useUi();
|
||||
const [libIcons, setLibIcons] = useState({});
|
||||
const suggestedPalettes = useMemo(() => getSuggestedPalettes(), []);
|
||||
// "Automatique" (value '') suit le thème par défaut de l'administrateur —
|
||||
// sa pastille reflète donc la couleur réellement appliquée dans ce cas.
|
||||
const colorThemeOptions = useMemo(() => [
|
||||
{ value: '', label: 'Automatique (thème par défaut)', swatch: colorThemeSwatch(adminColorTheme) },
|
||||
...COLOR_THEMES,
|
||||
], [adminColorTheme]);
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/icons').then(rows => {
|
||||
@@ -271,6 +316,25 @@ export default function AppearanceSection() {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Thème de couleur personnel (06/09/26) : indépendant du défaut admin
|
||||
(Admin > Général), stocké en base sur ce compte (cf. UiContext.jsx) */}
|
||||
<div style={{ marginTop: 20, paddingTop: 20, borderTop: '1px solid var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 16, flexWrap: 'wrap' }}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 700, fontSize: 'var(--fs-sm)' }}>Couleur de l'application</div>
|
||||
<div style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', marginTop: 2, maxWidth: 420 }}>
|
||||
Teinte des menus et boutons, propre à votre compte et conservée d'un appareil à l'autre.
|
||||
« Automatique » suit le thème par défaut choisi par l'administrateur.
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ width: 260, flexShrink: 0 }}>
|
||||
<ThemeSelect
|
||||
value={colorTheme || ''}
|
||||
onChange={(v) => setColorTheme(v || null)}
|
||||
options={colorThemeOptions}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
@@ -310,6 +374,7 @@ export default function AppearanceSection() {
|
||||
interets={chartInterets}
|
||||
capital={chartCapital}
|
||||
cashback={chartCashback}
|
||||
palettes={suggestedPalettes}
|
||||
onSelect={(p) => { setChartInterets(p.interets); setChartCapital(p.capital); setChartCashback(p.cashback); }}
|
||||
/>
|
||||
<div className="chart-color-row">
|
||||
|
||||
+443
-48
@@ -38,8 +38,11 @@
|
||||
--sidebar-hover: #163080;
|
||||
--sidebar-border: #2a4a9e;
|
||||
--sidebar-accent: #4ade80;
|
||||
--avatar-entreprise-start: #3730a3;
|
||||
--avatar-entreprise-end: #4338ca;
|
||||
--primary: #1e40af;
|
||||
--primary-hover: #1e3a8a;
|
||||
--primary-rgb: 30,64,175;
|
||||
--accent: #4ade80;
|
||||
--accent-hover: #22c55e;
|
||||
--success: #16a34a;
|
||||
@@ -78,8 +81,11 @@
|
||||
--sidebar-hover: #0d1a40;
|
||||
--sidebar-border: #1e3a6a;
|
||||
--sidebar-accent: #4ade80;
|
||||
--primary: #3b82f6;
|
||||
--primary-hover: #60a5fa;
|
||||
--avatar-entreprise-start: #3730a3;
|
||||
--avatar-entreprise-end: #4338ca;
|
||||
--primary: #3659ca;
|
||||
--primary-hover: #5b77d3;
|
||||
--primary-rgb: 54,89,202;
|
||||
--accent: #4ade80;
|
||||
--accent-hover: #86efac;
|
||||
--success: #4ade80;
|
||||
@@ -103,6 +109,361 @@
|
||||
--success-bg: #14532d; --success-fg: #bbf7d0;
|
||||
}
|
||||
|
||||
/* ================= Thème d'interface (teinte) ================= */
|
||||
/* Indépendant du mode clair/sombre (data-theme) ci-dessus : rotation de
|
||||
* teinte (même saturation/luminosité par jeton) appliquée au bleu par
|
||||
* défaut, y compris --sidebar-text (labels du menu) et le dégradé
|
||||
* "entreprise" des avatars (--avatar-entreprise-*, distinct de --primary
|
||||
* par un écart de teinte fixe, préservé par la rotation dans tous les
|
||||
* thèmes). "classique" = valeurs par défaut ci-dessus, aucun bloc nécessaire.
|
||||
* Réglage global (Admin > Général), cf. project_theme_interface.md.
|
||||
*
|
||||
* 05/09/26 : chacun des 9 thèmes ci-dessous reprend la teinte de la couleur
|
||||
* "Intérêts" d'une des 10 palettes de graphiques suggérées dans Settings >
|
||||
* Apparence (getSuggestedPalettes, cf. utils/chartPalettes.js et
|
||||
* project_chart_colors.md).
|
||||
*
|
||||
* 05/09/26 (suite, même jour) : les couleurs Intérêts d'indigo_teal/nuit/
|
||||
* frais/pastel ont été retravaillées — elles dérivaient toutes du bleu et
|
||||
* étaient peu distinguables entre elles (deltas de quelques degrés à
|
||||
* peine). Les 9 thèmes sont maintenant répartis sur tout le cercle
|
||||
* chromatique (deltas recalculés depuis l'ancre #1e40af, écart minimal
|
||||
* ~22°, la plupart ≥30°) : rouge (contraste), orange (coucher_de_soleil),
|
||||
* vert (nature), vert-teal (indigo_teal), cyan (frais), bleu (classique,
|
||||
* ancre), indigo (nuit), violet (violet), magenta (moderne), rose
|
||||
* (pastel). Contraste et nature sont restés inchangés (déjà bien
|
||||
* distincts) ; coucher_de_soleil/violet/moderne n'ont eu qu'un léger
|
||||
* réajustement de teinte pour dégager de la place. */
|
||||
|
||||
/* 06/09/26 : sur demande d'Olivier, --primary (clair) de chacun des 9 thèmes
|
||||
* est désormais EXACTEMENT la couleur "Intérêts" de sa palette de graphiques
|
||||
* jumelle (plus seulement une teinte approchante à même S/L que l'ancre
|
||||
* #1e40af) — un seul écart HSL (teinte/saturation/luminosité) est calculé
|
||||
* entre l'ancien --primary calculé et cette couleur cible, puis appliqué
|
||||
* uniquement à la "famille primaire" du thème (--primary, --primary-hover,
|
||||
* --sidebar-bg/-hover en clair, --primary/-hover en sombre, --shadow) ; les
|
||||
* teintes de fond très pâles (--bg, --surface-2, --border, --input-border,
|
||||
* --text-muted, --sidebar-border, --sidebar-text, --avatar-entreprise-*)
|
||||
* restent inchangées (l'écart de teinte pur était de toute façon proche de
|
||||
* 0°, ces teintes étaient déjà correctes) — appliquer le même écart S/L à
|
||||
* ces tokens quasi blancs/noirs les aurait saturés à #fff/#000 (piège
|
||||
* rencontré et écarté, cf. project_theme_interface.md). */
|
||||
[data-theme="light"][data-color-theme="indigo_teal"] {
|
||||
--bg: #f0fff9;
|
||||
--surface-2: #e8f8f0;
|
||||
--border: #c7e8d8;
|
||||
--input-border: #c7e8d8;
|
||||
--text-muted: #4a806a;
|
||||
--sidebar-bg: #1e7452;
|
||||
--sidebar-hover: #166a49;
|
||||
--sidebar-border: #2a9e6d;
|
||||
--sidebar-text: #bfeed7;
|
||||
--primary: #20976a;
|
||||
--primary-hover: #1e7452;
|
||||
--primary-rgb: 32,151,106;
|
||||
--avatar-entreprise-start: #30a399;
|
||||
--avatar-entreprise-end: #38cabf;
|
||||
--shadow: 0 1px 3px rgba(30,116,82,.10), 0 1px 2px rgba(30,116,82,.06);
|
||||
}
|
||||
[data-theme="dark"][data-color-theme="indigo_teal"] {
|
||||
--bg: #061f13;
|
||||
--surface: #113524;
|
||||
--surface-2: #0d291c;
|
||||
--border: #1e6a43;
|
||||
--input-bg: #0d291c;
|
||||
--input-border: #1e6a43;
|
||||
--text-muted: #7ab899;
|
||||
--sidebar-bg: #061f13;
|
||||
--sidebar-hover: #0d402b;
|
||||
--sidebar-border: #1e6a43;
|
||||
--sidebar-text: #8adcaf;
|
||||
--primary: #37b384;
|
||||
--primary-hover: #4fc99b;
|
||||
--primary-rgb: 55,179,132;
|
||||
--avatar-entreprise-start: #30a399;
|
||||
--avatar-entreprise-end: #38cabf;
|
||||
}
|
||||
[data-theme="light"][data-color-theme="frais"] {
|
||||
--bg: #f0fdff;
|
||||
--surface-2: #e8f7f8;
|
||||
--border: #c7e5e8;
|
||||
--input-border: #c7e5e8;
|
||||
--text-muted: #4a7780;
|
||||
--sidebar-bg: #057f97;
|
||||
--sidebar-hover: #00718a;
|
||||
--sidebar-border: #2a8d9e;
|
||||
--sidebar-text: #bfeaee;
|
||||
--primary: #009cc1;
|
||||
--primary-hover: #057f97;
|
||||
--primary-rgb: 0,156,193;
|
||||
--avatar-entreprise-start: #306ba3;
|
||||
--avatar-entreprise-end: #3881ca;
|
||||
--shadow: 0 1px 3px rgba(5,127,151,.10), 0 1px 2px rgba(5,127,151,.06);
|
||||
}
|
||||
[data-theme="dark"][data-color-theme="frais"] {
|
||||
--bg: #061c1f;
|
||||
--surface: #113035;
|
||||
--surface-2: #0d2629;
|
||||
--border: #1e666a;
|
||||
--input-bg: #0d2629;
|
||||
--input-border: #1e666a;
|
||||
--text-muted: #7ab4b8;
|
||||
--sidebar-bg: #061c1f;
|
||||
--sidebar-hover: #0d3740;
|
||||
--sidebar-border: #1e666a;
|
||||
--sidebar-text: #8adadc;
|
||||
--primary: #1bb5d9;
|
||||
--primary-hover: #3bc6e7;
|
||||
--primary-rgb: 27,181,217;
|
||||
--avatar-entreprise-start: #306ba3;
|
||||
--avatar-entreprise-end: #3881ca;
|
||||
}
|
||||
[data-theme="light"][data-color-theme="nuit"] {
|
||||
--bg: #f1f0ff;
|
||||
--surface-2: #e8e8f8;
|
||||
--border: #c8c7e8;
|
||||
--input-border: #c8c7e8;
|
||||
--text-muted: #504a80;
|
||||
--sidebar-bg: #3f368a;
|
||||
--sidebar-hover: #362b83;
|
||||
--sidebar-border: #342a9e;
|
||||
--sidebar-text: #c0bfee;
|
||||
--primary: #4839ac;
|
||||
--primary-hover: #3f368a;
|
||||
--primary-rgb: 72,57,172;
|
||||
--avatar-entreprise-start: #6130a3;
|
||||
--avatar-entreprise-end: #7838ca;
|
||||
--shadow: 0 1px 3px rgba(63,54,138,.10), 0 1px 2px rgba(63,54,138,.06);
|
||||
}
|
||||
[data-theme="dark"][data-color-theme="nuit"] {
|
||||
--bg: #07061f;
|
||||
--surface: #131135;
|
||||
--surface-2: #0e0d29;
|
||||
--border: #1e1e6a;
|
||||
--input-bg: #0e0d29;
|
||||
--input-border: #1e1e6a;
|
||||
--text-muted: #7b7ab8;
|
||||
--sidebar-bg: #07061f;
|
||||
--sidebar-hover: #130d40;
|
||||
--sidebar-border: #1e1e6a;
|
||||
--sidebar-text: #8a8ddc;
|
||||
--primary: #695dbb;
|
||||
--primary-hover: #877dc9;
|
||||
--primary-rgb: 105,93,187;
|
||||
--avatar-entreprise-start: #6130a3;
|
||||
--avatar-entreprise-end: #7838ca;
|
||||
}
|
||||
[data-theme="light"][data-color-theme="nature"] {
|
||||
--bg: #f0fff0;
|
||||
--surface-2: #eaf8e8;
|
||||
--border: #c9e8c7;
|
||||
--input-border: #c9e8c7;
|
||||
--text-muted: #4a804b;
|
||||
--sidebar-bg: #3e8040;
|
||||
--sidebar-hover: #337935;
|
||||
--sidebar-border: #2a9e2a;
|
||||
--sidebar-text: #c2eebf;
|
||||
--primary: #43a047;
|
||||
--primary-hover: #3e8040;
|
||||
--primary-rgb: 67,160,71;
|
||||
--avatar-entreprise-start: #30a357;
|
||||
--avatar-entreprise-end: #38ca6c;
|
||||
--shadow: 0 1px 3px rgba(62,128,64,.10), 0 1px 2px rgba(62,128,64,.06);
|
||||
}
|
||||
[data-theme="dark"][data-color-theme="nature"] {
|
||||
--bg: #071f06;
|
||||
--surface: #123511;
|
||||
--surface-2: #0e290d;
|
||||
--border: #256a1e;
|
||||
--input-bg: #0e290d;
|
||||
--input-border: #256a1e;
|
||||
--text-muted: #7fb87a;
|
||||
--sidebar-bg: #071f06;
|
||||
--sidebar-hover: #0d400e;
|
||||
--sidebar-border: #256a1e;
|
||||
--sidebar-text: #94dc8a;
|
||||
--primary: #64b267;
|
||||
--primary-hover: #83c185;
|
||||
--primary-rgb: 100,178,103;
|
||||
--avatar-entreprise-start: #30a357;
|
||||
--avatar-entreprise-end: #38ca6c;
|
||||
}
|
||||
[data-theme="light"][data-color-theme="coucher_de_soleil"] {
|
||||
--bg: #fff9f0;
|
||||
--surface-2: #f8efe8;
|
||||
--border: #e8d8c7;
|
||||
--input-border: #e8d8c7;
|
||||
--text-muted: #80694a;
|
||||
--sidebar-bg: #f49008;
|
||||
--sidebar-hover: #ea8900;
|
||||
--sidebar-border: #9e6b2a;
|
||||
--sidebar-text: #eed6bf;
|
||||
--primary: #ffa722;
|
||||
--primary-hover: #f49008;
|
||||
--primary-rgb: 255,167,34;
|
||||
--avatar-entreprise-start: #a39730;
|
||||
--avatar-entreprise-end: #cabd38;
|
||||
--shadow: 0 1px 3px rgba(244,144,8,.10), 0 1px 2px rgba(244,144,8,.06);
|
||||
}
|
||||
[data-theme="dark"][data-color-theme="coucher_de_soleil"] {
|
||||
--bg: #1f1306;
|
||||
--surface: #352411;
|
||||
--surface-2: #291b0d;
|
||||
--border: #6a411e;
|
||||
--input-bg: #291b0d;
|
||||
--input-border: #6a411e;
|
||||
--text-muted: #b8987a;
|
||||
--sidebar-bg: #1f1306;
|
||||
--sidebar-hover: #402b0d;
|
||||
--sidebar-border: #6a411e;
|
||||
--sidebar-text: #dcad8a;
|
||||
--primary: #ecb665;
|
||||
--primary-hover: #f0c789;
|
||||
--primary-rgb: 236,182,101;
|
||||
--avatar-entreprise-start: #a39730;
|
||||
--avatar-entreprise-end: #cabd38;
|
||||
}
|
||||
[data-theme="light"][data-color-theme="violet"] {
|
||||
--bg: #f7f0ff;
|
||||
--surface-2: #eee8f8;
|
||||
--border: #d4c7e8;
|
||||
--input-border: #d4c7e8;
|
||||
--text-muted: #644a80;
|
||||
--sidebar-bg: #7749ab;
|
||||
--sidebar-hover: #703ca6;
|
||||
--sidebar-border: #5f2a9e;
|
||||
--sidebar-text: #d2bfee;
|
||||
--primary: #8d57c2;
|
||||
--primary-hover: #7749ab;
|
||||
--primary-rgb: 141,87,194;
|
||||
--avatar-entreprise-start: #8b30a3;
|
||||
--avatar-entreprise-end: #ae38ca;
|
||||
--shadow: 0 1px 3px rgba(119,73,171,.10), 0 1px 2px rgba(119,73,171,.06);
|
||||
}
|
||||
[data-theme="dark"][data-color-theme="violet"] {
|
||||
--bg: #10061f;
|
||||
--surface: #201135;
|
||||
--surface-2: #190d29;
|
||||
--border: #3a1e6a;
|
||||
--input-bg: #190d29;
|
||||
--input-border: #3a1e6a;
|
||||
--text-muted: #927ab8;
|
||||
--sidebar-bg: #10061f;
|
||||
--sidebar-hover: #250d40;
|
||||
--sidebar-border: #3a1e6a;
|
||||
--sidebar-text: #a58adc;
|
||||
--primary: #a684c8;
|
||||
--primary-hover: #bda3d6;
|
||||
--primary-rgb: 166,132,200;
|
||||
--avatar-entreprise-start: #8b30a3;
|
||||
--avatar-entreprise-end: #ae38ca;
|
||||
}
|
||||
[data-theme="light"][data-color-theme="contraste"] {
|
||||
--bg: #fff0f0;
|
||||
--surface-2: #f8e8ea;
|
||||
--border: #e8c7ca;
|
||||
--input-border: #e8c7ca;
|
||||
--text-muted: #804a4a;
|
||||
--sidebar-bg: #d12424;
|
||||
--sidebar-hover: #c91c1a;
|
||||
--sidebar-border: #9e2a2c;
|
||||
--sidebar-text: #eebfc3;
|
||||
--primary: #e53935;
|
||||
--primary-hover: #d12424;
|
||||
--primary-rgb: 229,57,53;
|
||||
--avatar-entreprise-start: #a35530;
|
||||
--avatar-entreprise-end: #ca6938;
|
||||
--shadow: 0 1px 3px rgba(209,36,36,.10), 0 1px 2px rgba(209,36,36,.06);
|
||||
}
|
||||
[data-theme="dark"][data-color-theme="contraste"] {
|
||||
--bg: #1f0608;
|
||||
--surface: #351113;
|
||||
--surface-2: #290d0f;
|
||||
--border: #6a1e26;
|
||||
--input-bg: #290d0f;
|
||||
--input-border: #6a1e26;
|
||||
--text-muted: #b87a80;
|
||||
--sidebar-bg: #1f0608;
|
||||
--sidebar-hover: #400d0d;
|
||||
--sidebar-border: #6a1e26;
|
||||
--sidebar-text: #dc8a96;
|
||||
--primary: #df716e;
|
||||
--primary-hover: #e79593;
|
||||
--primary-rgb: 223,113,110;
|
||||
--avatar-entreprise-start: #a35530;
|
||||
--avatar-entreprise-end: #ca6938;
|
||||
}
|
||||
[data-theme="light"][data-color-theme="moderne"] {
|
||||
--bg: #fff0ff;
|
||||
--surface-2: #f6e8f8;
|
||||
--border: #e5c7e8;
|
||||
--input-border: #e5c7e8;
|
||||
--text-muted: #7f4a80;
|
||||
--sidebar-bg: #89268c;
|
||||
--sidebar-hover: #821d83;
|
||||
--sidebar-border: #992a9e;
|
||||
--sidebar-text: #e9bfee;
|
||||
--primary: #b027b0;
|
||||
--primary-hover: #89268c;
|
||||
--primary-rgb: 176,39,176;
|
||||
--avatar-entreprise-start: #a33081;
|
||||
--avatar-entreprise-end: #ca389d;
|
||||
--shadow: 0 1px 3px rgba(137,38,140,.10), 0 1px 2px rgba(137,38,140,.06);
|
||||
}
|
||||
[data-theme="dark"][data-color-theme="moderne"] {
|
||||
--bg: #1d061f;
|
||||
--surface: #331135;
|
||||
--surface-2: #270d29;
|
||||
--border: #601e6a;
|
||||
--input-bg: #270d29;
|
||||
--input-border: #601e6a;
|
||||
--text-muted: #b17ab8;
|
||||
--sidebar-bg: #1d061f;
|
||||
--sidebar-hover: #3f0d40;
|
||||
--sidebar-border: #601e6a;
|
||||
--sidebar-text: #ce8adc;
|
||||
--primary: #c545c5;
|
||||
--primary-hover: #d068d0;
|
||||
--primary-rgb: 197,69,197;
|
||||
--avatar-entreprise-start: #a33081;
|
||||
--avatar-entreprise-end: #ca389d;
|
||||
}
|
||||
[data-theme="light"][data-color-theme="pastel"] {
|
||||
--bg: #fff0f8;
|
||||
--surface-2: #f8e8f2;
|
||||
--border: #e8c7da;
|
||||
--input-border: #e8c7da;
|
||||
--text-muted: #804a66;
|
||||
--sidebar-bg: #eb2288;
|
||||
--sidebar-hover: #ef0c7d;
|
||||
--sidebar-border: #9e2a67;
|
||||
--sidebar-text: #eebfdb;
|
||||
--primary: #f53d97;
|
||||
--primary-hover: #eb2288;
|
||||
--primary-rgb: 245,61,151;
|
||||
--avatar-entreprise-start: #a33046;
|
||||
--avatar-entreprise-end: #ca3852;
|
||||
--shadow: 0 1px 3px rgba(235,34,136,.10), 0 1px 2px rgba(235,34,136,.06);
|
||||
}
|
||||
[data-theme="dark"][data-color-theme="pastel"] {
|
||||
--bg: #1f0614;
|
||||
--surface: #351125;
|
||||
--surface-2: #290d1d;
|
||||
--border: #6a1e4d;
|
||||
--input-bg: #290d1d;
|
||||
--input-border: #6a1e4d;
|
||||
--text-muted: #b87aa0;
|
||||
--sidebar-bg: #1f0614;
|
||||
--sidebar-hover: #400d27;
|
||||
--sidebar-border: #6a1e4d;
|
||||
--sidebar-text: #dc8ac0;
|
||||
--primary: #e868a7;
|
||||
--primary-hover: #ee8cbc;
|
||||
--primary-rgb: 232,104,167;
|
||||
--avatar-entreprise-start: #a33046;
|
||||
--avatar-entreprise-end: #ca3852;
|
||||
}
|
||||
|
||||
/* ================= Base ================= */
|
||||
* { box-sizing: border-box; }
|
||||
html, body, #root { height: 100%; margin: 0; }
|
||||
@@ -250,10 +611,10 @@ a { color: var(--primary); }
|
||||
border: none; border-radius: 20px;
|
||||
font-size: var(--fs-base); font-weight: 600;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 8px rgba(59,130,246,.35);
|
||||
box-shadow: 0 2px 8px rgba(var(--primary-rgb),.35);
|
||||
transition: background .15s, box-shadow .15s;
|
||||
}
|
||||
.btn-add-invest:hover { background: var(--primary-hover); box-shadow: 0 3px 12px rgba(59,130,246,.5); }
|
||||
.btn-add-invest:hover { background: var(--primary-hover); box-shadow: 0 3px 12px rgba(var(--primary-rgb),.5); }
|
||||
|
||||
/* ── Boutons génériques ──────────────────────────────────────── */
|
||||
.btn {
|
||||
@@ -373,7 +734,7 @@ a { color: var(--primary); }
|
||||
border-radius: 20px;
|
||||
padding: 3px;
|
||||
gap: 1px;
|
||||
box-shadow: 0 2px 8px rgba(59,130,246,.35);
|
||||
box-shadow: 0 2px 8px rgba(var(--primary-rgb),.35);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.display-toggle-btn {
|
||||
@@ -420,7 +781,7 @@ a { color: var(--primary); }
|
||||
/* Sidebar toujours sombre → icônes toujours inversées */
|
||||
.sidebar-nav .nav-lib-icon {
|
||||
filter: invert(1);
|
||||
opacity: 0.85;
|
||||
opacity: 1;
|
||||
}
|
||||
.sidebar-nav a.active .nav-lib-icon {
|
||||
filter: invert(1);
|
||||
@@ -431,16 +792,16 @@ a { color: var(--primary); }
|
||||
|
||||
/* Thème sombre : fond du toggle plus discret */
|
||||
[data-theme="dark"] .display-toggle {
|
||||
background: rgba(59,130,246,.25);
|
||||
background: rgba(var(--primary-rgb),.25);
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,.4);
|
||||
border: 1px solid rgba(59,130,246,.4);
|
||||
border: 1px solid rgba(var(--primary-rgb),.4);
|
||||
}
|
||||
[data-theme="dark"] .display-toggle-btn.active {
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
}
|
||||
[data-theme="dark"] .display-toggle-btn:hover:not(.active) {
|
||||
background: rgba(59,130,246,.2);
|
||||
background: rgba(var(--primary-rgb),.2);
|
||||
color: rgba(255,255,255,.9);
|
||||
}
|
||||
|
||||
@@ -594,7 +955,7 @@ input, select, textarea {
|
||||
}
|
||||
input:focus, select:focus, textarea:focus {
|
||||
outline: none; border-color: var(--primary);
|
||||
box-shadow: 0 0 0 2px rgba(59,130,246,.2);
|
||||
box-shadow: 0 0 0 2px rgba(var(--primary-rgb),.2);
|
||||
}
|
||||
label {
|
||||
display: block; font-size: var(--fs-sm); font-weight: 500;
|
||||
@@ -704,13 +1065,13 @@ tr:hover td { background: var(--surface-2); }
|
||||
.pref-option:hover {
|
||||
border-color: var(--primary);
|
||||
background: var(--surface-hover, var(--surface));
|
||||
box-shadow: 0 0 0 3px rgba(59,130,246,.12);
|
||||
box-shadow: 0 0 0 3px rgba(var(--primary-rgb),.12);
|
||||
}
|
||||
|
||||
.pref-option.active {
|
||||
border-color: var(--primary);
|
||||
background: rgba(59,130,246,.07);
|
||||
box-shadow: 0 0 0 3px rgba(59,130,246,.18);
|
||||
background: rgba(var(--primary-rgb),.07);
|
||||
box-shadow: 0 0 0 3px rgba(var(--primary-rgb),.18);
|
||||
}
|
||||
|
||||
.pref-option svg,
|
||||
@@ -745,7 +1106,7 @@ tr:hover td { background: var(--surface-2); }
|
||||
.import-module-note {
|
||||
display: flex; align-items: flex-start; gap: 6px;
|
||||
margin-top: 10px; padding: 8px 12px;
|
||||
background: rgba(59,130,246,.07); border: 1px solid rgba(59,130,246,.2);
|
||||
background: rgba(var(--primary-rgb),.07); border: 1px solid rgba(var(--primary-rgb),.2);
|
||||
border-radius: 6px; font-size: var(--fs-sm); color: var(--text-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
@@ -850,7 +1211,7 @@ tr:hover td { background: var(--surface-2); }
|
||||
.sg-tooltip-value {
|
||||
font-size: var(--fs-md);
|
||||
font-weight: 700;
|
||||
color: #4fa8e8; /* même bleu que la courbe */
|
||||
color: var(--primary); /* même couleur que la courbe (thème actif) */
|
||||
letter-spacing: -.01em;
|
||||
}
|
||||
|
||||
@@ -1106,6 +1467,41 @@ tr:hover td { background: var(--surface-2); }
|
||||
.cat-select-empty { padding: 10px 12px; font-size: var(--fs-xs); color: var(--text-muted); }
|
||||
.cat-select-err { padding: 4px 12px; font-size: var(--fs-xs); color: var(--danger); }
|
||||
|
||||
/* ThemeSelect (Admin > Général > Apparence : sélecteur de thème) */
|
||||
.theme-select-wrap { position: relative; width: 100%; }
|
||||
.theme-select-trigger {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
width: 100%; padding: 0 10px;
|
||||
border: 1px solid var(--input-border); border-radius: 6px;
|
||||
min-height: 38px; background: var(--input-bg);
|
||||
font-size: var(--fs-sm); color: var(--text);
|
||||
cursor: pointer; text-align: left;
|
||||
transition: border-color .12s;
|
||||
}
|
||||
.theme-select-trigger:hover { border-color: var(--primary); }
|
||||
.theme-select-trigger.open { border-color: var(--primary); }
|
||||
.theme-select-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.theme-select-swatch {
|
||||
display: block; flex-shrink: 0; width: 16px; height: 16px; border-radius: 50%;
|
||||
box-shadow: 0 0 0 1px var(--border);
|
||||
}
|
||||
.theme-select-dropdown {
|
||||
position: absolute; top: calc(100% + 4px); left: 0; right: 0;
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
border-radius: 8px; box-shadow: var(--shadow); z-index: 40;
|
||||
max-height: 260px; overflow-y: auto; padding: 4px;
|
||||
}
|
||||
.theme-select-item {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
width: 100%; padding: 7px 8px; border-radius: 6px;
|
||||
border: none; background: none; cursor: pointer; text-align: left;
|
||||
font-size: var(--fs-sm); color: var(--text);
|
||||
transition: background .1s;
|
||||
}
|
||||
.theme-select-item:hover { background: var(--surface-2); }
|
||||
.theme-select-item.active { font-weight: 600; }
|
||||
.theme-select-item-label { flex: 1; }
|
||||
|
||||
/* ================= Depots/Retraits Tabs + Layout ================= */
|
||||
.dr-tabs {
|
||||
display: flex;
|
||||
@@ -1137,7 +1533,7 @@ tr:hover td { background: var(--surface-2); }
|
||||
|
||||
.dr-row { transition: background .12s; }
|
||||
.dr-row:hover td { background: var(--surface-2) !important; }
|
||||
.dr-row-selected td { background: rgba(59,130,246,.08) !important; }
|
||||
.dr-row-selected td { background: rgba(var(--primary-rgb),.08) !important; }
|
||||
|
||||
.dr-detail {
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
@@ -1215,7 +1611,7 @@ tr:hover td { background: var(--surface-2); }
|
||||
}
|
||||
.dr-kpi-clickable:hover {
|
||||
border-color: var(--border);
|
||||
box-shadow: 0 0 0 3px rgba(59,130,246,.12);
|
||||
box-shadow: 0 0 0 3px rgba(var(--primary-rgb),.12);
|
||||
}
|
||||
.dr-kpi-active {
|
||||
border-width: 1.5px !important;
|
||||
@@ -1232,8 +1628,8 @@ tr:hover td { background: var(--surface-2); }
|
||||
}
|
||||
.dr-kpi-active-neutral {
|
||||
border-color: var(--primary) !important;
|
||||
box-shadow: 0 0 0 3px rgba(59,130,246,.14) !important;
|
||||
background: rgba(59,130,246,.05);
|
||||
box-shadow: 0 0 0 3px rgba(var(--primary-rgb),.14) !important;
|
||||
background: rgba(var(--primary-rgb),.05);
|
||||
}
|
||||
|
||||
/* Bouton "Effacer les filtres" dans la topbar */
|
||||
@@ -1370,7 +1766,7 @@ tr:hover td { background: var(--surface-2); }
|
||||
color: var(--text);
|
||||
}
|
||||
.account-nav-item.active {
|
||||
background: rgba(59,130,246,.12);
|
||||
background: rgba(var(--primary-rgb),.12);
|
||||
color: var(--primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -1620,12 +2016,12 @@ tr:hover td { background: var(--surface-2); }
|
||||
.palette-card:hover {
|
||||
border-color: var(--primary);
|
||||
background: var(--surface-hover, var(--surface));
|
||||
box-shadow: 0 0 0 3px rgba(59,130,246,.12);
|
||||
box-shadow: 0 0 0 3px rgba(var(--primary-rgb),.12);
|
||||
}
|
||||
.palette-card.active {
|
||||
border-color: var(--primary);
|
||||
background: rgba(59,130,246,.07);
|
||||
box-shadow: 0 0 0 3px rgba(59,130,246,.18);
|
||||
background: rgba(var(--primary-rgb),.07);
|
||||
box-shadow: 0 0 0 3px rgba(var(--primary-rgb),.18);
|
||||
}
|
||||
.palette-swatches {
|
||||
display: flex;
|
||||
@@ -1768,7 +2164,7 @@ tr:hover td { background: var(--surface-2); }
|
||||
|
||||
/* En-têtes */
|
||||
.tip-th-year {
|
||||
background: linear-gradient(135deg, #7c3aed 0%, #4f46e5 100%);
|
||||
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-hover) 100%);
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
font-weight: 700;
|
||||
@@ -1778,7 +2174,7 @@ tr:hover td { background: var(--surface-2); }
|
||||
}
|
||||
.tip-th-total,
|
||||
.tip-th-avg {
|
||||
background: linear-gradient(135deg, #7c3aed 0%, #4f46e5 100%);
|
||||
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-hover) 100%);
|
||||
color: #fff;
|
||||
text-align: right;
|
||||
font-weight: 700;
|
||||
@@ -1786,7 +2182,7 @@ tr:hover td { background: var(--surface-2); }
|
||||
padding: 6px 10px;
|
||||
}
|
||||
.tip-th-name {
|
||||
background: linear-gradient(135deg, #7c3aed 0%, #4f46e5 100%);
|
||||
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-hover) 100%);
|
||||
color: #fff;
|
||||
text-align: left;
|
||||
padding: 6px 12px;
|
||||
@@ -1839,8 +2235,8 @@ tr:hover td { background: var(--surface-2); }
|
||||
text-align: right;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
background: rgba(109,40,217,.06);
|
||||
border-left: 1px solid rgba(109,40,217,.15);
|
||||
background: rgba(var(--primary-rgb), .06);
|
||||
border-left: 1px solid rgba(var(--primary-rgb), .15);
|
||||
}
|
||||
.tip-td-avg {
|
||||
padding: 7px 10px;
|
||||
@@ -1848,8 +2244,8 @@ tr:hover td { background: var(--surface-2); }
|
||||
white-space: nowrap;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--fs-xs);
|
||||
background: rgba(109,40,217,.04);
|
||||
border-left: 1px solid rgba(109,40,217,.1);
|
||||
background: rgba(var(--primary-rgb), .04);
|
||||
border-left: 1px solid rgba(var(--primary-rgb), .1);
|
||||
}
|
||||
|
||||
/* Valeurs projetées */
|
||||
@@ -1860,7 +2256,7 @@ tr:hover td { background: var(--surface-2); }
|
||||
.tip-td-clickable { cursor: pointer; }
|
||||
.tip-row-plat:hover .tip-td-clickable { text-decoration: underline dotted; text-underline-offset: 2px; }
|
||||
.tip-td-active {
|
||||
background: rgba(var(--primary-rgb, 99,102,241), 0.14) !important;
|
||||
background: rgba(var(--primary-rgb), 0.14) !important;
|
||||
font-weight: 700;
|
||||
color: var(--primary);
|
||||
outline: 2px solid var(--primary);
|
||||
@@ -1872,17 +2268,17 @@ tr:hover td { background: var(--surface-2); }
|
||||
background: var(--surface-2) !important;
|
||||
}
|
||||
.tip-td-closed.tip-col-current {
|
||||
background: rgba(109,40,217,0.045) !important;
|
||||
background: rgba(var(--primary-rgb), 0.045) !important;
|
||||
}
|
||||
[data-theme="dark"] .tip-td-closed {
|
||||
background: rgba(255,255,255,0.04) !important;
|
||||
}
|
||||
[data-theme="dark"] .tip-td-closed.tip-col-current {
|
||||
background: rgba(109,40,217,0.08) !important;
|
||||
background: rgba(var(--primary-rgb), 0.08) !important;
|
||||
}
|
||||
|
||||
/* Cellule cliquable (lien vers mouvements) */
|
||||
.tip-td-link:hover { background: var(--primary-light, rgba(109,40,217,.1)) !important; color: var(--primary); }
|
||||
.tip-td-link:hover { background: rgba(var(--primary-rgb), .1) !important; color: var(--primary); }
|
||||
|
||||
/* Lignes corps */
|
||||
.tip-row-plat { border-bottom: 1px solid var(--border); }
|
||||
@@ -1915,20 +2311,19 @@ tr:hover td { background: var(--surface-2); }
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
.tip-footer-total {
|
||||
background: rgba(109,40,217,.08);
|
||||
background: rgba(var(--primary-rgb), .08);
|
||||
font-weight: 700;
|
||||
}
|
||||
.tip-footer-total td { border-top: 2px solid rgba(109,40,217,.3); }
|
||||
.tip-footer-total td { border-top: 2px solid rgba(var(--primary-rgb), .3); }
|
||||
.tip-footer-capital { color: var(--text-muted); font-style: italic; }
|
||||
.tip-footer-capital td { border-top: 1px solid var(--border); }
|
||||
.tip-footer-perf td { border-top: 1px solid var(--border); }
|
||||
.tip-footer-total td.tip-td-total { background: rgba(109,40,217,.12); border-left: 1px solid rgba(109,40,217,.2); }
|
||||
.tip-footer-total td.tip-td-total { background: rgba(var(--primary-rgb), .12); border-left: 1px solid rgba(var(--primary-rgb), .2); }
|
||||
|
||||
/* Dark mode ajustements */
|
||||
[data-theme="dark"] .tip-footer-total { background: rgba(217,119,6,.12); }
|
||||
[data-theme="dark"] .tip-td-total { background: rgba(217,119,6,.08); }
|
||||
[data-theme="dark"] .tip-td-avg { background: rgba(217,119,6,.04); border-left-color: rgba(217,119,6,.15); }
|
||||
[data-theme="dark"] .tip-th-name { background: linear-gradient(135deg, #7c3aed 0%, #4f46e5 100%); }
|
||||
/* Dark mode : plus d'overrides nécessaires ici — --primary-rgb et
|
||||
--primary/--primary-hover sont déjà redéfinis par [data-theme="dark"]
|
||||
(et par chaque thème de couleur), donc les règles de base ci-dessus
|
||||
résolvent automatiquement la bonne teinte dans les deux modes. */
|
||||
|
||||
/* Colonne mois courant */
|
||||
.tip-th-month-current {
|
||||
@@ -1941,25 +2336,25 @@ tr:hover td { background: var(--surface-2); }
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0;
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg, #7c3aed, #4f46e5);
|
||||
background: linear-gradient(90deg, var(--primary), var(--primary-hover));
|
||||
border-radius: 0 0 2px 2px;
|
||||
}
|
||||
.tip-col-current {
|
||||
background: rgba(109,40,217,0.045) !important;
|
||||
background: rgba(var(--primary-rgb), 0.045) !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
.tip-footer-total .tip-col-current {
|
||||
background: rgba(109,40,217,0.13) !important;
|
||||
background: rgba(var(--primary-rgb), 0.13) !important;
|
||||
}
|
||||
.tip-footer-capital .tip-col-current,
|
||||
.tip-footer-perf .tip-col-current {
|
||||
background: rgba(109,40,217,0.045) !important;
|
||||
background: rgba(var(--primary-rgb), 0.045) !important;
|
||||
}
|
||||
[data-theme="dark"] .tip-col-current {
|
||||
background: rgba(109,40,217,0.08) !important;
|
||||
background: rgba(var(--primary-rgb), 0.08) !important;
|
||||
}
|
||||
[data-theme="dark"] .tip-footer-total .tip-col-current {
|
||||
background: rgba(109,40,217,0.18) !important;
|
||||
background: rgba(var(--primary-rgb), 0.18) !important;
|
||||
}
|
||||
|
||||
.tip-th-empty {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* chartPalettes — les 10 palettes suggérées pour les couleurs de graphiques
|
||||
* (Settings > Apparence), partagées avec :
|
||||
* - AppearanceSection.jsx (grille de sélection + personnalisation)
|
||||
* - GeneralSection.jsx (adaptation automatique quand l'admin change le
|
||||
* thème d'interface global, cf. project_theme_interface.md)
|
||||
*
|
||||
* 06/09/26 : "Classique" a une couleur "Intérêts" FIXE (#1e40af, la couleur
|
||||
* --primary par défaut du thème "Classique" en Admin > Général), comme les
|
||||
* 9 autres palettes — ce n'était pas le cas avant (elle suivait en direct
|
||||
* --primary du thème d'interface actif, quel qu'il soit) : redevenu inutile
|
||||
* maintenant que chaque thème admin a sa propre palette jumelle qui
|
||||
* s'active automatiquement à sa sélection (cf. project_theme_interface.md
|
||||
* V4/V6) — Classique n'a plus besoin de "suivre" le thème actif, elle est
|
||||
* elle-même l'un des 10 choix fixes. Les 9 autres couleurs "Intérêts" sont
|
||||
* fixes depuis le 05/09/26, choisies pour bien se distinguer les unes des
|
||||
* autres sur tout le cercle chromatique (les anciennes valeurs de
|
||||
* indigo_teal/nuit/frais/pastel dérivaient toutes du bleu et étaient peu
|
||||
* distinguables entre elles) — cf. project_chart_colors.md. Les couleurs
|
||||
* Capital/Cashback de chaque palette restent inchangées.
|
||||
*/
|
||||
export function getSuggestedPalettes() {
|
||||
return [
|
||||
{ name: 'Classique', interets: '#1e40af', capital: '#4caf50', cashback: '#ffc107' },
|
||||
{ name: 'Indigo & Teal', interets: '#20976a', capital: '#009688', cashback: '#fb8c00' },
|
||||
{ name: 'Nuit', interets: '#4839ac', capital: '#4dd0e1', cashback: '#ffca28' },
|
||||
{ name: 'Nature', interets: '#43a047', capital: '#039be5', cashback: '#ff9800' },
|
||||
{ name: 'Coucher de soleil', interets: '#ffa722', capital: '#1976d2', cashback: '#e91e63' },
|
||||
{ name: 'Violet', interets: '#8d57c2', capital: '#26a69a', cashback: '#ffb300' },
|
||||
{ name: 'Frais', interets: '#009cc1', capital: '#7cb342', cashback: '#ec407a' },
|
||||
{ name: 'Contraste', interets: '#e53935', capital: '#1e88e5', cashback: '#43a047' },
|
||||
{ name: 'Pastel', interets: '#f53d97', capital: '#66bb6a', cashback: '#ffb74d' },
|
||||
{ name: 'Moderne', interets: '#b027b0', capital: '#00bcd4', cashback: '#ff9800' },
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* colorThemes.js — liste partagée des 10 thèmes de couleur d'interface
|
||||
* (Admin > Général, cf. project_theme_interface.md), et de leur association
|
||||
* avec la palette de graphiques correspondante (Settings > Apparence, cf.
|
||||
* chartPalettes.js).
|
||||
*
|
||||
* Utilisé par GeneralSection.jsx (thème par défaut de l'application,
|
||||
* Admin > Général) ET AppearanceSection.jsx (thème personnel de
|
||||
* l'utilisateur, Settings > Apparence, 06/09/26) — auparavant dupliqué
|
||||
* uniquement côté admin ; extrait ici pour que les deux sélecteurs restent
|
||||
* strictement synchronisés (mêmes valeurs, mêmes libellés, même swatch).
|
||||
*/
|
||||
|
||||
// 05/09/26 (suite, même jour) : les couleurs "Intérêts" des palettes
|
||||
// indigo_teal/nuit/frais/pastel ont été retravaillées (trop proches du bleu
|
||||
// classique et peu distinguables entre elles) pour une meilleure diversité
|
||||
// sur tout le cercle chromatique — cf. utils/chartPalettes.js et
|
||||
// project_theme_interface.md. Le swatch de chaque thème reprend exactement
|
||||
// la couleur --primary (mode clair) de ce thème dans styles.css.
|
||||
export const COLOR_THEMES = [
|
||||
{ value: 'classique', label: 'Classique (défaut)', swatch: '#1e40af' },
|
||||
{ value: 'indigo_teal', label: 'Indigo & Teal', swatch: '#20976a' },
|
||||
{ value: 'frais', label: 'Frais', swatch: '#009cc1' },
|
||||
{ value: 'nuit', label: 'Nuit', swatch: '#4839ac' },
|
||||
{ value: 'nature', label: 'Nature', swatch: '#43a047' },
|
||||
{ value: 'coucher_de_soleil', label: 'Coucher de soleil', swatch: '#ffa722' },
|
||||
{ value: 'violet', label: 'Violet', swatch: '#8d57c2' },
|
||||
{ value: 'contraste', label: 'Contraste', swatch: '#e53935' },
|
||||
{ value: 'moderne', label: 'Moderne', swatch: '#b027b0' },
|
||||
{ value: 'pastel', label: 'Pastel', swatch: '#f53d97' },
|
||||
];
|
||||
|
||||
// Nom de la palette (Settings > Apparence, cf. utils/chartPalettes.js)
|
||||
// correspondant à chaque thème : sert à adapter automatiquement les
|
||||
// couleurs de graphiques d'un compte quand ce thème est choisi (admin par
|
||||
// défaut, ou personnel — cf. reverse-coupling dans GeneralSection.jsx et
|
||||
// AppearanceSection.jsx).
|
||||
export const THEME_TO_PALETTE_NAME = {
|
||||
classique: 'Classique',
|
||||
indigo_teal: 'Indigo & Teal',
|
||||
nuit: 'Nuit',
|
||||
nature: 'Nature',
|
||||
coucher_de_soleil: 'Coucher de soleil',
|
||||
violet: 'Violet',
|
||||
frais: 'Frais',
|
||||
contraste: 'Contraste',
|
||||
pastel: 'Pastel',
|
||||
moderne: 'Moderne',
|
||||
};
|
||||
|
||||
export function colorThemeSwatch(themeValue) {
|
||||
return COLOR_THEMES.find(t => t.value === themeValue)?.swatch || COLOR_THEMES[0].swatch;
|
||||
}
|
||||
Reference in new issue
Block a user