Compare commits

...

10 Commits

Author SHA1 Message Date
Paperclip
7f0688d00a feat(HRT-143): Refonte design /depenses/ glassmorphism + modern UI
- Refonte complète index.html (1444 lignes) :
  layout 3 colonnes desktop / mobile responsive
  Header sticky glassmorphism avec nav tabs
  4 KPI cards animées (total, budget restant, nb, max)
  Donut chart répartition catégories en sidebar droite
  Top 5 dépenses avec progress bars
  Formulaire avec labels uppercase + inputs focus glow
  Liste dépenses avec badges colorés par catégorie/statut
  Modales overlay backdrop-blur + animation spring
  Budget bar gradient animée (matrix → rose selon %)

- Refonte complète dashboard.html (731 lignes) :
  Grid 3 colonnes avec cards glassmorphism
  4 KPI cards (total, count, moyenne, catégories)
  Chart principal multi-mode (bar/line/pie)
  Donut répartition + table catégories avec %
  Résumé par personne avec progress bars
  Top 7 dépenses avec progress bars
  Sparkline tendance 6 derniers mois

- Stack : HTML/CSS/JS vanilla + Tailwind CDN + JetBrains Mono
- Palette : --bg-primary #020617, --cyan #00d9ff, --violet #a855f7
- 0 modification app.py, toutes APIs inchangées

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-11 16:01:58 +02:00
h3r7
6d3ef9dec3 Documentation v1.0 - Pour vente 2026-03-01 17:45:05 +01:00
h3r7
d3b9f1c664 v1.6 - Camembert sur Dashboard 2026-03-01 08:22:51 +01:00
h3r7
83a850d520 v1.5 - Dashboard avec filtres (Mois/Personne/Catégorie) 2026-03-01 07:54:55 +01:00
h3r7
31e5a6bc29 v1.4 - Catégories auto + Budget + Récurrent + PDF + Tests 2026-03-01 07:31:05 +01:00
h3r7
38616e6823 v1.3 - Migration SQLite (au lieu de JSON) 2026-02-28 08:58:10 +01:00
h3r7
b40bf56c97 v1.2 - Dashboard with charts, Import/Export CSV, 17 dépenses 2026-02-28 08:53:48 +01:00
h3r7
64c7a976f7 v1.1 - Import/Export CSV, 17 dépenses restaurées 2026-02-28 08:21:03 +01:00
h3r7
47e4648a03 v1.0 - Status, envoi par ligne, config restaurée 2026-02-27 14:49:07 +01:00
h3r7
d507e4e0a6 v1.0 - Dépenses Trello avec status, envoi par ligne 2026-02-27 13:39:08 +01:00
7 changed files with 2580 additions and 300 deletions

157
DOCUMENTATION.md Normal file
View File

@@ -0,0 +1,157 @@
# 💸 Dépenses Trello - Application de Gestion des Dépenses
## 📝 Description
**Dépenses Trello** est une application web de gestion des dépenses personnelles avec synchronisation automatique vers Trello. Créée pour simplifier le suivi des dépenses au quotidien.
---
## ✨ Fonctionnalités
### Gestion des Dépenses
- **Ajout rapide** de dépenses (prénom, date, libellé, montant, catégorie)
- ✏️ **Modification** des dépenses existantes
- 🗑️ **Suppression** de dépenses
- 📤 **Export CSV** pour Excel/comptabilité
- 📥 **Import CSV** pour récupérer des données
- 📄 **Export PDF** pour les relevés
### Catégories Automatiques
- 🚗 **Transport** (essence, gazole, parking)
- 🛒 **Courses** (Carrefour, Lidl, Auchan)
- 🎰 **Loisirs** (loto, bar, café, cinéma)
- 🏠 **Maison** (lumière, loyer, brico)
- ❤️ **Santé** (pharmacie, contrôle technique)
- 📦 **Autre** (divers)
### Graphiques & Statistiques
- 📊 **Bar chart** - visualisation classique
- 🥧 **Camembert** - répartition par catégorie
- 👤 **Par personne** - suivi par membre du foyer
- 📅 **Par mois** - évolution dans le temps
### Budget
- 💰 **Budget mensuel** configurable
- ⚠️ **Alerte** automatique quand le budget est dépassé
### Dépenses Récurrentes
- 🔄 **Loyer**, EDF, téléphone - automatiquement listés
### Synchronisation Trello
- 🟦 **Envoi automatique** des dépenses vers une liste Trello
-**Suivi du statut** (En attente / Envoyé)
---
## 🛠️ Stack Technique
| Composant | Technologie |
|-----------|--------------|
| **Backend** | Python Flask |
| **Base de données** | SQLite |
| **Frontend** | HTML5, Vanilla JavaScript |
| **Graphiques** | Chart.js |
| **PDF** | jsPDF |
| **Hébergement** | VPS (Linux) |
---
## 📋 Spécifications
- **Port**: 8769
- **URL**: http://178.18.250.53:8769/
- **API REST**: /api/depenses, /api/config, /api/budget, /api/recurring
- **Données**: 21+ dépenses en base (extensible)
- **Utilisateurs**: Multi-utilisateurs (via prénom)
---
## 🚀 Installation
```bash
# Cloner le projet
git clone http://178.18.250.53:3000/admin/Perso.git
# Installer les dépendances
pip install flask requests
# Lancer l'application
python app.py
```
---
## 💼 Potentiel Commercial
### Cible
- 👨‍👩‍👧 **Particuliers** gestion budget familial
- 💼 **Auto-entrepreneurs** frais professionnels
- 🏢 **Petites entreprises** suivi dépenses
### Arguments de Vente
1.**Simple** - interface épurée, pas de formation
2.**Complet** - catégories, graphiques, budget, PDF
3.**Automatisé** - synchronisation Trello
4.**Pas d'abonnement** - hébergement propre
5.**Open Source** - customisable
### Prix Recommandés
| Offre | Prix |
|-------|------|
| Usage personnel | 19€ |
| Usage pro | 49€ |
| Installation + config | 29€ |
| Support mensuel | 9€/mois |
---
## 📱 Captures d'Écran
### Page Saisie
- Formulaire rapide avec catégories auto
- Liste des dernières dépenses
- Boutons Envoyer tout / Export
### Page Dashboard
- Graphiques Bar / Camembert
- Filtres Mois / Personne / Catégorie
- Total en temps réel
### Page Config
- Gestion des prénoms
- Personnalisation des catégories
- Configuration Trello (API Key, Token, List ID)
- Budget mensuel
---
## 🔧 Configuration Trello
1. Créer un Power-Up sur https://trello.com/power-ups/admin
2. Générer une API Key
3. Générer un Token (avec permissions write)
4. Copier le List ID cible
5. Coller dans Config
---
## 📝 Roadmap Future
- [ ] Application mobile (PWA)
- [ ] Mode hors-ligne
- [ ] Catégories personnalisées illimitées
- [ ] Rapports mensuels par email
- [ ] Intégration Slack/Discord
- [ ] Multi-devises
---
## 📞 Contact
**Développé par**: H3R7Tech
**Date**: Mars 2026
**Version**: 1.6
---
*Document généré automatiquement - Dépenses Trello*

Binary file not shown.

335
app.py
View File

@@ -1,82 +1,279 @@
#!/usr/bin/env python3
from flask import Flask, request, jsonify, redirect
import json
from flask import Flask, request, jsonify, redirect, send_file
import sqlite3
import os
import requests
import csv
import io
app = Flask(__name__)
CONFIG_FILE = '/home/h3r7/depenses_trello/config.json'
DB_FILE = '/home/h3r7/depenses_trello/depenses.db'
def load_config():
with open(CONFIG_FILE, 'r') as f:
return json.load(f)
def init_db():
conn = sqlite3.connect(DB_FILE)
c = conn.cursor()
c.execute('''CREATE TABLE IF NOT EXISTS depenses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
prenom TEXT,
date TEXT,
libelle TEXT,
montant REAL,
status TEXT DEFAULT 'En attente',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)''')
c.execute('''CREATE TABLE IF NOT EXISTS config (
key TEXT PRIMARY KEY,
value TEXT
)''')
c.execute('''CREATE TABLE IF NOT EXISTS prenoms (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE
)''')
conn.commit()
conn.close()
def save_config(data):
with open(CONFIG_FILE, 'w') as f:
json.dump(data, f, indent=2)
def get_db():
conn = sqlite3.connect(DB_FILE)
conn.row_factory = sqlite3.Row
return conn
init_db()
def parse_date(d):
if not d: return ""
if "/" in d:
parts = d.split("/")
if len(parts) == 3:
return f"{parts[2]}-{parts[1]}-{parts[0]}"
return d
@app.route('/')
def index():
with open('/home/h3r7/depenses_trello/templates/index.html', 'r') as f:
return f.read()
@app.route('/dashboard')
def dashboard():
with open('/home/h3r7/depenses_trello/templates/dashboard.html', 'r') as f:
return f.read()
# API Config
@app.route('/api/config')
def get_config():
return jsonify(load_config())
conn = get_db()
c = conn.cursor()
# Get prenoms
c.execute('SELECT name FROM prenoms')
prenoms = [row[0] for row in c.fetchall()]
# Get format
c.execute("SELECT value FROM config WHERE key='format'")
format_row = c.fetchone()
format_val = format_row[0] if format_row else '{prenom} - {date} - {libelle} - {montant}'
# Get trello config
c.execute("SELECT value FROM config WHERE key='trello'")
trello_row = c.fetchone()
trello = {}
if trello_row:
import json
trello = json.loads(trello_row[0])
# Get categories
c.execute("SELECT value FROM config WHERE key='categories'")
cat_row = c.fetchone()
categories = cat_row[0].split(',') if cat_row else ['Courses', 'Essence', 'Loisirs', 'Maison', 'Santé', 'Transport', 'Autre']
conn.close()
return jsonify({
'format': format_val,
'prenoms': prenoms,
'categories': categories,
'trello': trello
})
# Add depense
@app.route('/api/add', methods=['POST'])
def add_depense():
config = load_config()
data = {
'id': len(config.get('depenses', [])) + 1,
'prenom': request.form.get('prenom'),
'date': request.form.get('date'),
'libelle': request.form.get('libelle'),
'montant': float(request.form.get('montant', 0))
}
if 'depenses' not in config:
config['depenses'] = []
config['depenses'].append(data)
save_config(config)
return redirect('/')
conn = get_db()
c = conn.cursor()
c.execute('INSERT INTO depenses (prenom, date, libelle, montant, status, category) VALUES (?, ?, ?, ?, ?, ?)',
(request.form.get('prenom'), parse_date(request.form.get('date', '')),
request.form.get('libelle'), float(request.form.get('montant', 0)), 'En attente', request.form.get('category', 'Autre')))
conn.commit()
conn.close()
return redirect('/?page=saisie')
# Get depenses
@app.route('/api/depenses')
def get_depenses():
config = load_config()
return jsonify(config.get('depenses', []))
conn = get_db()
c = conn.cursor()
c.execute('SELECT id, prenom, date, libelle, montant, status, category FROM depenses ORDER BY date DESC')
rows = c.fetchall()
conn.close()
return jsonify([dict(row) for row in rows])
# Delete depense
@app.route('/api/del/<int:id>')
def delete_depense(id):
config = load_config()
config['depenses'] = [d for d in config.get('depenses', []) if d.get('id') != id]
save_config(config)
conn = get_db()
c = conn.cursor()
c.execute('DELETE FROM depenses WHERE id=?', (id,))
conn.commit()
conn.close()
return redirect('/?page=saisie')
# Clear all
@app.route('/api/clear', methods=['POST'])
def clear_depenses():
config = load_config()
config['depenses'] = []
save_config(config)
conn = get_db()
c = conn.cursor()
c.execute('DELETE FROM depenses')
conn.commit()
conn.close()
return jsonify({'success': True})
# Export CSV
@app.route('/api/export')
def export_csv():
conn = get_db()
c = conn.cursor()
c.execute('SELECT prenom, date, libelle, montant, status FROM depenses ORDER BY date DESC')
rows = c.fetchall()
conn.close()
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(['prenom', 'date', 'libelle', 'montant', 'status'])
for row in rows:
writer.writerow(row)
output.seek(0)
return send_file(
io.BytesIO(output.getvalue().encode('utf-8')),
mimetype='text/csv',
as_attachment=True,
download_name='depenses_2026.csv'
)
# Import CSV
@app.route('/api/import', methods=['POST'])
def import_csv():
if 'file' not in request.files:
return jsonify({'error': 'Aucun fichier'}), 400
file = request.files['file']
if file.filename == '':
return jsonify({'error': 'Fichier vide'}), 400
try:
content = file.read().decode('utf-8')
reader = csv.reader(content.splitlines())
next(reader)
conn = get_db()
c = conn.cursor()
imported = 0
for row in reader:
if len(row) >= 4:
c.execute('INSERT INTO depenses (prenom, date, libelle, montant, status) VALUES (?, ?, ?, ?, ?)',
(row[0].strip(), row[1].strip(), row[2].strip(),
float(row[3]) if row[3] else 0, row[4].strip() if len(row) > 4 else 'En attente'))
imported += 1
conn.commit()
conn.close()
return jsonify({'success': True, 'imported': imported})
except Exception as e:
return jsonify({'error': str(e)}), 400
# Send ONE to Trello
@app.route('/api/trello/send_one/<int:id>', methods=['POST'])
def send_one(id):
conn = get_db()
c = conn.cursor()
c.execute("SELECT value FROM config WHERE key='trello'")
trello_row = c.fetchone()
if not trello_row:
conn.close()
return jsonify({'error': 'Config Trello manquante'}), 400
import json
t = json.loads(trello_row[0])
if not t.get('api_key') or not t.get('token') or not t.get('list_id'):
conn.close()
return jsonify({'error': 'Config Trello incomplète'}), 400
c.execute('SELECT * FROM depenses WHERE id=?', (id,))
d = c.fetchone()
if not d:
conn.close()
return jsonify({'error': 'Dépense non trouvée'}), 404
if d['status'] == 'Envoyé ✅':
conn.close()
return jsonify({'error': 'Déjà envoyé'}), 400
# Generate text
c.execute("SELECT value FROM config WHERE key='format'")
format_row = c.fetchone()
template = format_row[0] if format_row else '{prenom} - {date} - {libelle} - {montant}'
ddate = d['date'] or ''
if ddate:
ddate = '/'.join(ddate.split('-')[::-1])
line = template
line = line.replace('{prenom}', d['prenom'] or '')
line = line.replace('{date}', ddate)
line = line.replace('{libelle}', d['libelle'] or '')
line = line.replace('{montant}', str(d['montant']))
# Send to Trello
url = 'https://api.trello.com/1/cards'
params = {
'key': t.get('api_key'),
'token': t.get('token'),
'idList': t.get('list_id'),
'name': line,
'desc': line
}
try:
r = requests.post(url, params=params)
if r.status_code == 200:
c.execute('UPDATE depenses SET status=? WHERE id=?', ('Envoyé ✅', id))
conn.commit()
conn.close()
return jsonify({'success': True})
conn.close()
return jsonify({'error': r.text}), r.status_code
except Exception as e:
conn.close()
return jsonify({'error': str(e)}), 500
# Generate text (all)
@app.route('/api/generate', methods=['POST'])
def generate_text():
config = load_config()
conn = get_db()
c = conn.cursor()
c.execute("SELECT value FROM config WHERE key='format'")
format_row = c.fetchone()
template = format_row[0] if format_row else '{prenom} - {date} - {libelle} - {montant}'
conn.close()
data = request.json
template = config.get('format', '{prenom} - {date} - {libelle} - {montant}')
lines = []
for d in data.get('depenses', []):
line = template
line = line.replace('{prenom}', d.get('prenom', ''))
ddate = d.get('date', '') or ''
if ddate:
ddate = '/'.join(ddate.split('-')[::-1])
line = line.replace('{prenom}', d.get('prenom', ''))
line = line.replace('{date}', ddate)
line = line.replace('{libelle}', d.get('libelle', ''))
line = line.replace('{montant}', str(d.get('montant', 0)))
@@ -86,68 +283,54 @@ def generate_text():
# Add prenom
@app.route('/api/prenom/add', methods=['POST'])
def add_prenom():
config = load_config()
conn = get_db()
c = conn.cursor()
prenom = request.form.get('prenom', '').strip()
if prenom and prenom not in config.get('prenom', []):
if 'prenoms' not in config:
config['prenoms'] = []
config['prenoms'].append(prenom)
save_config(config)
if prenom:
try:
c.execute('INSERT INTO prenoms (name) VALUES (?)', (prenom,))
conn.commit()
except:
pass
conn.close()
return redirect('/?page=config')
# Del prenom
@app.route('/api/prenom/del/<int:idx>')
def del_prenom(idx):
config = load_config()
if 0 <= idx < len(config.get('prenoms', [])):
config['prenoms'].pop(idx)
save_config(config)
conn = get_db()
c = conn.cursor()
c.execute('DELETE FROM prenoms WHERE id=?', (idx+1,))
conn.commit()
conn.close()
return redirect('/?page=config')
# Save format
@app.route('/api/format', methods=['POST'])
def save_format():
config = load_config()
config['format'] = request.form.get('format', '{prenom} - {date} - {libelle} - {montant}')
save_config(config)
conn = get_db()
c = conn.cursor()
format_val = request.form.get('format', '{prenom} - {date} - {libelle} - {montant}')
c.execute('INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)', ('format', format_val))
conn.commit()
conn.close()
return redirect('/?page=config')
# Save trello config
@app.route('/api/trello/save', methods=['POST'])
def save_trello():
config = load_config()
config['trello'] = {
import json
conn = get_db()
c = conn.cursor()
t = json.dumps({
'api_key': request.form.get('api_key', ''),
'token': request.form.get('token', ''),
'list_id': request.form.get('list_id', '')
}
save_config(config)
})
c.execute('INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)', ('trello', t))
conn.commit()
conn.close()
return redirect('/?page=config')
# Send to Trello
@app.route('/api/trello/send', methods=['POST'])
def send_trello():
config = load_config()
t = config.get('trello', {})
if not t.get('api_key') or not t.get('token') or not t.get('list_id'):
return jsonify({'error': 'Config Trello manquante'}), 400
data = request.json
url = 'https://api.trello.com/1/cards'
params = {
'key': t.get('api_key'),
'token': t.get('token'),
'idList': t.get('list_id'),
'name': data.get('title', 'Dépenses'),
'desc': data.get('text', '')
}
try:
r = requests.post(url, params=params)
if r.status_code == 200:
return jsonify({'success': True})
return jsonify({'error': r.text}), r.status_code
except Exception as e:
return jsonify({'error': str(e)}), 500
if __name__ == '__main__':
app.run(host='0.0.0.0', port=8769, debug=False)

View File

@@ -1,8 +1,6 @@
{
"prenoms": [
"HERY"
],
"format": "{prenom} - {date} - {libelle} - {montant}\u20ac",
"prenoms": ["Papa", "Maman", "H3R7"],
"format": "{prenom} - {date} - {libelle} - {montant}€",
"depenses": [],
"trello": {
"api_key": "",

BIN
depenses.db Normal file

Binary file not shown.

731
templates/dashboard.html Normal file
View File

@@ -0,0 +1,731 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>📊 Dashboard Dépenses — H3R7</title>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="https://cdn.tailwindcss.com"></script>
<style>
:root {
--bg-primary: #020617;
--bg-card: rgba(15, 23, 42, 0.8);
--bg-input: rgba(30, 41, 59, 0.6);
--cyan: #00d9ff;
--violet: #a855f7;
--matrix: #00ff88;
--rose: #f43f5e;
--amber: #f59e0b;
--text: #f1f5f9;
--text-dim: #64748b;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Inter', sans-serif;
background: var(--bg-primary);
color: var(--text);
min-height: 100vh;
overflow-x: hidden;
}
/* Grid background */
body::before {
content: '';
position: fixed;
inset: 0;
background-image:
linear-gradient(rgba(0, 217, 255, 0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(0, 217, 255, 0.03) 1px, transparent 1px);
background-size: 40px 40px;
pointer-events: none;
z-index: 0;
}
body::after {
content: '';
position: fixed;
top: -200px;
left: 50%;
transform: translateX(-50%);
width: 800px;
height: 400px;
background: radial-gradient(ellipse, rgba(168,85,247,0.06) 0%, transparent 70%);
pointer-events: none;
z-index: 0;
}
/* Glass card */
.glass-card {
background: rgba(15, 23, 42, 0.7);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid rgba(0, 217, 255, 0.1);
border-radius: 16px;
transition: border-color 0.3s, box-shadow 0.3s;
}
.glass-card:hover {
border-color: rgba(0, 217, 255, 0.2);
}
/* Header */
header {
position: sticky;
top: 0;
z-index: 50;
background: rgba(2, 6, 23, 0.85);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-bottom: 1px solid rgba(168, 85, 247, 0.2);
padding: 12px 20px;
}
.logo-text {
font-family: 'JetBrains Mono', monospace;
font-weight: 700;
font-size: 1.1rem;
background: linear-gradient(135deg, var(--violet), var(--cyan));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* Nav tabs */
.nav-tab {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
border-radius: 8px;
font-size: 0.875rem;
font-weight: 500;
color: var(--text-dim);
text-decoration: none;
transition: all 0.2s;
border: 1px solid transparent;
}
.nav-tab:hover { color: var(--text); background: rgba(168,85,247,0.08); border-color: rgba(168,85,247,0.15); }
.nav-tab.active { color: var(--violet); background: rgba(168,85,247,0.12); border-color: rgba(168,85,247,0.3); }
/* KPI cards */
.kpi-card {
background: rgba(15, 23, 42, 0.7);
backdrop-filter: blur(16px);
border: 1px solid rgba(0, 217, 255, 0.1);
border-radius: 14px;
padding: 18px 20px;
position: relative;
overflow: hidden;
transition: all 0.3s;
}
.kpi-card::before {
content: '';
position: absolute;
top: 0; left: 0; right: 0;
height: 2px;
background: linear-gradient(90deg, transparent, var(--kpi-color, var(--cyan)), transparent);
}
.kpi-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 30px rgba(0,0,0,0.3);
border-color: rgba(0, 217, 255, 0.2);
}
.kpi-value {
font-family: 'JetBrains Mono', monospace;
font-size: 1.6rem;
font-weight: 700;
}
/* Filter chips */
.filter-chip {
padding: 7px 16px;
background: rgba(30, 41, 59, 0.6);
border: 1px solid rgba(100, 116, 139, 0.2);
border-radius: 20px;
font-size: 0.82rem;
cursor: pointer;
transition: all 0.2s;
color: var(--text-dim);
white-space: nowrap;
}
.filter-chip:hover { border-color: rgba(168,85,247,0.3); color: var(--text); }
.filter-chip.active { background: rgba(168,85,247,0.15); border-color: rgba(168,85,247,0.4); color: var(--violet); }
.filter-chip.mode-active { background: rgba(0,217,255,0.12); border-color: rgba(0,217,255,0.35); color: var(--cyan); }
/* Section title */
.section-title {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-dim);
margin-bottom: 14px;
display: flex;
align-items: center;
gap: 8px;
}
.section-title::after {
content: '';
flex: 1;
height: 1px;
background: rgba(100, 116, 139, 0.2);
}
/* Summary tables */
.summary-table { width: 100%; border-collapse: collapse; }
.summary-table th {
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-dim);
padding: 6px 0;
text-align: left;
border-bottom: 1px solid rgba(100,116,139,0.15);
}
.summary-table td {
padding: 8px 0;
font-size: 0.85rem;
border-bottom: 1px solid rgba(100,116,139,0.07);
}
.summary-table tr:last-child td { border-bottom: none; }
/* Progress bar */
.prog-bar {
height: 5px;
background: rgba(100,116,139,0.2);
border-radius: 3px;
overflow: hidden;
margin-top: 4px;
}
.prog-fill {
height: 100%;
border-radius: 3px;
background: linear-gradient(90deg, var(--violet), var(--cyan));
}
/* Home button */
.home-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 12px;
background: rgba(168, 85, 247, 0.1);
border: 1px solid rgba(168, 85, 247, 0.25);
border-radius: 8px;
color: var(--violet);
text-decoration: none;
font-size: 0.8rem;
font-weight: 500;
transition: all 0.2s;
}
.home-btn:hover { background: rgba(168, 85, 247, 0.18); box-shadow: 0 2px 12px rgba(168,85,247,0.2); }
/* Scrollbar */
::-webkit-scrollbar { width: 5px; height: 5px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: rgba(100,116,139,0.3); border-radius: 3px; }
/* Grid layout */
.dash-grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 16px;
padding: 16px;
max-width: 1400px;
margin: 0 auto;
position: relative;
z-index: 1;
}
.span-2 { grid-column: span 2; }
.span-3 { grid-column: span 3; }
@media (max-width: 1024px) {
.dash-grid { grid-template-columns: 1fr 1fr; }
.span-3 { grid-column: span 2; }
}
@media (max-width: 640px) {
.dash-grid { grid-template-columns: 1fr; }
.span-2, .span-3 { grid-column: span 1; }
}
/* Animation */
@keyframes slideIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.glass-card { animation: slideIn 0.3s ease-out; }
/* Total badge */
.total-badge {
font-family: 'JetBrains Mono', monospace;
font-weight: 700;
font-size: 1.8rem;
background: linear-gradient(135deg, var(--rose), var(--violet));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
</style>
</head>
<body>
<!-- HEADER -->
<header>
<div style="display:flex;align-items:center;justify-content:space-between;max-width:1400px;margin:0 auto">
<div style="display:flex;align-items:center;gap:16px">
<a href="https://portal-kolifee.duckdns.org/" class="home-btn">🏠 Portail</a>
<div class="logo-text">H3R7 / Dashboard Dépenses</div>
</div>
<nav style="display:flex;gap:6px">
<a href="/depenses/?page=saisie" class="nav-tab">✏️ Saisie</a>
<a href="/depenses/dashboard" class="nav-tab active" id="nav-dashboard">📊 Dashboard</a>
<a href="/depenses/?page=config" class="nav-tab">⚙️ Config</a>
</nav>
<div style="font-family:'JetBrains Mono',monospace;font-size:0.78rem;color:var(--text-dim);text-align:right">
<div style="color:var(--violet)" id="header-period"></div>
<div id="header-total-small" style="color:var(--rose);font-weight:600">0.00€</div>
</div>
</div>
</header>
<!-- KPI BAR -->
<div style="padding:12px 16px;position:relative;z-index:1">
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:12px;max-width:1400px;margin:0 auto">
<div class="kpi-card" style="--kpi-color:var(--rose)">
<div style="font-size:0.72rem;font-weight:600;text-transform:uppercase;letter-spacing:0.08em;color:var(--text-dim);margin-bottom:6px">Total période</div>
<div class="kpi-value" id="kpi-total" style="color:var(--rose)">0.00€</div>
</div>
<div class="kpi-card" style="--kpi-color:var(--cyan)">
<div style="font-size:0.72rem;font-weight:600;text-transform:uppercase;letter-spacing:0.08em;color:var(--text-dim);margin-bottom:6px">Nb dépenses</div>
<div class="kpi-value" id="kpi-count" style="color:var(--cyan)">0</div>
</div>
<div class="kpi-card" style="--kpi-color:var(--violet)">
<div style="font-size:0.72rem;font-weight:600;text-transform:uppercase;letter-spacing:0.08em;color:var(--text-dim);margin-bottom:6px">Moyenne</div>
<div class="kpi-value" id="kpi-avg" style="color:var(--violet)">0.00€</div>
</div>
<div class="kpi-card" style="--kpi-color:var(--amber)">
<div style="font-size:0.72rem;font-weight:600;text-transform:uppercase;letter-spacing:0.08em;color:var(--text-dim);margin-bottom:6px">Catégories actives</div>
<div class="kpi-value" id="kpi-cats" style="color:var(--amber)">0</div>
</div>
</div>
</div>
<!-- MAIN DASHBOARD GRID -->
<div class="dash-grid">
<!-- Filter controls — full width -->
<div class="glass-card span-3" style="padding:14px 18px">
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap">
<div style="font-size:0.75rem;font-weight:600;text-transform:uppercase;letter-spacing:0.08em;color:var(--text-dim);white-space:nowrap">Vue par</div>
<div style="display:flex;gap:8px">
<div class="filter-chip active" id="fb-month" onclick="setFilter('month')">📅 Mois</div>
<div class="filter-chip" id="fb-person" onclick="setFilter('person')">👤 Personne</div>
<div class="filter-chip" id="fb-category" onclick="setFilter('category')">📂 Catégorie</div>
</div>
<div style="width:1px;height:20px;background:rgba(100,116,139,0.2)"></div>
<div style="font-size:0.75rem;font-weight:600;text-transform:uppercase;letter-spacing:0.08em;color:var(--text-dim);white-space:nowrap">Valeur</div>
<div style="display:flex;gap:8px;overflow-x:auto;flex:1" id="filter-bar"></div>
</div>
</div>
<!-- Main chart — 2 columns -->
<div class="glass-card span-2" style="padding:18px">
<div class="section-title">
📈 Évolution
<div style="margin-left:auto;display:flex;gap:6px">
<div class="filter-chip mode-active" id="cm-bar" onclick="setChartMode('bar')" style="padding:4px 12px;font-size:0.75rem">Bar</div>
<div class="filter-chip" id="cm-line" onclick="setChartMode('line')" style="padding:4px 12px;font-size:0.75rem">Line</div>
<div class="filter-chip" id="cm-pie" onclick="setChartMode('pie')" style="padding:4px 12px;font-size:0.75rem">Pie</div>
</div>
</div>
<div style="height:280px;position:relative">
<canvas id="mainChart"></canvas>
</div>
</div>
<!-- Donut chart — 1 column -->
<div class="glass-card" style="padding:18px">
<div class="section-title">🍩 Répartition catégories</div>
<div style="height:180px;position:relative">
<canvas id="categoryChart"></canvas>
</div>
<div id="category-summary" style="margin-top:12px"></div>
</div>
<!-- Per person — 1 column -->
<div class="glass-card" style="padding:18px">
<div class="section-title">👤 Par personne</div>
<div id="person-summary"></div>
</div>
<!-- Top expenses — 1 column -->
<div class="glass-card" style="padding:18px">
<div class="section-title">🏆 Top dépenses</div>
<div id="top-expenses"></div>
</div>
<!-- Monthly trend — 1 column -->
<div class="glass-card" style="padding:18px">
<div class="section-title">📆 Tendance mensuelle</div>
<div style="height:160px;position:relative">
<canvas id="trendChart"></canvas>
</div>
</div>
</div>
<script>
var depenses = [];
var currentFilter = { type: 'month', value: null };
var chartMode = 'bar';
var mainChart = null;
var categoryChart = null;
var trendChart = null;
Chart.defaults.color = '#64748b';
Chart.defaults.borderColor = 'rgba(100, 116, 139, 0.08)';
var COLORS = ['#00d9ff','#a855f7','#00ff88','#f43f5e','#f59e0b','#38bdf8','#fb7185','#34d399','#fbbf24','#60a5fa'];
function getFilteredData() {
if (!currentFilter.value) return depenses;
if (currentFilter.type === 'month') return depenses.filter(function(d) { return d.date && d.date.startsWith(currentFilter.value); });
if (currentFilter.type === 'person') return depenses.filter(function(d) { return d.prenom === currentFilter.value; });
if (currentFilter.type === 'category') return depenses.filter(function(d) { return (d.category || 'Autre') === currentFilter.value; });
return depenses;
}
function setFilter(type) {
currentFilter.type = type;
currentFilter.value = null;
// Update filter type buttons
['month','person','category'].forEach(function(t) {
var el = document.getElementById('fb-'+t);
if (el) el.className = 'filter-chip' + (t === type ? ' active' : '');
});
var values = [];
if (type === 'month') values = [...new Set(depenses.map(function(d) { return d.date.split('-')[0]+'-'+d.date.split('-')[1]; }))].sort().reverse();
else if (type === 'person') values = [...new Set(depenses.map(function(d) { return d.prenom; }))].sort();
else if (type === 'category') values = [...new Set(depenses.map(function(d) { return d.category || 'Autre'; }))].sort();
currentFilter.value = values[0] || null;
renderFilters(values);
updateAll();
}
function renderFilters(values) {
if (!values) {
values = [];
if (currentFilter.type === 'month') values = [...new Set(depenses.map(function(d) { return d.date.split('-')[0]+'-'+d.date.split('-')[1]; }))].sort().reverse();
else if (currentFilter.type === 'person') values = [...new Set(depenses.map(function(d) { return d.prenom; }))].sort();
else if (currentFilter.type === 'category') values = [...new Set(depenses.map(function(d) { return d.category || 'Autre'; }))].sort();
}
var html = '<div class="filter-chip' + (!currentFilter.value ? ' active' : '') + '" onclick="selectValue(null)">Tous</div>';
for (var i=0; i<values.length; i++) {
html += '<div class="filter-chip' + (currentFilter.value === values[i] ? ' active' : '') + '" onclick="selectValue(\'' + values[i] + '\')">' + values[i] + '</div>';
}
document.getElementById('filter-bar').innerHTML = html;
}
function selectValue(val) {
currentFilter.value = val;
renderFilters();
updateAll();
}
function setChartMode(mode) {
chartMode = mode;
['bar','line','pie'].forEach(function(m) {
var el = document.getElementById('cm-'+m);
if (el) el.className = 'filter-chip' + (m === mode ? ' mode-active' : '');
});
updateMainChart();
}
function updateAll() {
updateKPIs();
updateMainChart();
updateCategoryChart();
updatePersonSummary();
updateTopExpenses();
updateTrendChart();
}
function updateKPIs() {
var filtered = getFilteredData();
var total = filtered.reduce(function(s,d) { return s+(parseFloat(d.montant)||0); }, 0);
var cats = new Set(filtered.map(function(d) { return d.category || 'Autre'; }));
var avg = filtered.length > 0 ? total / filtered.length : 0;
document.getElementById('kpi-total').textContent = total.toFixed(2) + '€';
document.getElementById('kpi-count').textContent = filtered.length;
document.getElementById('kpi-avg').textContent = avg.toFixed(2) + '€';
document.getElementById('kpi-cats').textContent = cats.size;
document.getElementById('header-total-small').textContent = total.toFixed(2) + '€';
var period = currentFilter.value || 'Toutes périodes';
document.getElementById('header-period').textContent = period;
}
function updateMainChart() {
var ctx = document.getElementById('mainChart').getContext('2d');
var filtered = getFilteredData();
var labels = [], data = [], bgColors = [];
if (currentFilter.type === 'month') {
var months = {};
filtered.forEach(function(d) {
var m = d.date.split('-')[0]+'-'+d.date.split('-')[1];
months[m] = (months[m]||0) + (parseFloat(d.montant)||0);
});
Object.keys(months).sort().reverse().forEach(function(m, i) {
labels.push(m); data.push(months[m]); bgColors.push(COLORS[i % COLORS.length]);
});
} else if (currentFilter.type === 'person') {
var persons = {};
filtered.forEach(function(d) {
var p = d.prenom||'Inconnu';
persons[p] = (persons[p]||0) + (parseFloat(d.montant)||0);
});
Object.keys(persons).sort().forEach(function(p, i) {
labels.push(p); data.push(persons[p]); bgColors.push(COLORS[i % COLORS.length]);
});
} else if (currentFilter.type === 'category') {
var categories = {};
filtered.forEach(function(d) {
var c = d.category||'Autre';
categories[c] = (categories[c]||0) + (parseFloat(d.montant)||0);
});
Object.keys(categories).sort().forEach(function(c, i) {
labels.push(c); data.push(categories[c]); bgColors.push(COLORS[i % COLORS.length]);
});
}
if (mainChart) mainChart.destroy();
var type = chartMode === 'line' ? 'line' : (chartMode === 'pie' ? 'doughnut' : 'bar');
mainChart = new Chart(ctx, {
type: type,
data: {
labels: labels,
datasets: [{
label: 'Montant (€)',
data: data,
backgroundColor: type === 'bar' ? bgColors.map(function(c) { return c + '99'; }) : bgColors,
borderColor: bgColors,
borderWidth: type === 'bar' ? 0 : 2,
borderRadius: type === 'bar' ? 6 : 0,
fill: type === 'line' ? { target: 'origin', above: 'rgba(0,217,255,0.08)' } : undefined,
tension: 0.4,
pointBackgroundColor: bgColors,
pointRadius: type === 'line' ? 4 : 0,
cutout: type === 'doughnut' ? '65%' : undefined,
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: type === 'doughnut',
position: 'right',
labels: { color: '#94a3b8', font: { family: 'JetBrains Mono', size: 10 }, boxWidth: 10, padding: 8 }
},
tooltip: {
backgroundColor: 'rgba(15,23,42,0.95)',
borderColor: 'rgba(0,217,255,0.2)',
borderWidth: 1,
titleColor: '#94a3b8',
bodyColor: '#f1f5f9',
callbacks: {
label: function(ctx) { return ' ' + (parseFloat(ctx.raw)||0).toFixed(2) + ' €'; }
}
}
},
scales: type !== 'doughnut' ? {
y: {
beginAtZero: true,
grid: { color: 'rgba(100,116,139,0.08)' },
ticks: { color: '#64748b', font: { family: 'JetBrains Mono', size: 10 }, callback: function(v) { return v + '€'; } }
},
x: {
grid: { display: false },
ticks: { color: '#64748b', font: { size: 11 } }
}
} : {}
}
});
}
function updateCategoryChart() {
var filtered = getFilteredData();
var catData = {};
filtered.forEach(function(d) {
var c = d.category || 'Autre';
catData[c] = (catData[c]||0) + (parseFloat(d.montant)||0);
});
var catLabels = Object.keys(catData).sort();
var catValues = catLabels.map(function(c) { return catData[c]; });
var total = catValues.reduce(function(a,b) { return a+b; }, 0);
var ctx = document.getElementById('categoryChart').getContext('2d');
if (categoryChart) categoryChart.destroy();
categoryChart = new Chart(ctx, {
type: 'doughnut',
data: {
labels: catLabels,
datasets: [{ data: catValues, backgroundColor: COLORS.slice(0, catLabels.length), borderWidth: 0 }]
},
options: {
responsive: true, maintainAspectRatio: false, cutout: '70%',
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: 'rgba(15,23,42,0.95)',
borderColor: 'rgba(168,85,247,0.2)',
borderWidth: 1,
titleColor: '#94a3b8',
bodyColor: '#f1f5f9',
callbacks: {
label: function(ctx) {
var pct = total > 0 ? ((ctx.raw/total)*100).toFixed(1) : 0;
return ' ' + ctx.raw.toFixed(2) + '€ (' + pct + '%)';
}
}
}
}
}
});
// Category summary table
var html = '<table class="summary-table">';
html += '<tr><th>Catégorie</th><th>Total</th><th>%</th></tr>';
catLabels.forEach(function(c, i) {
var pct = total > 0 ? (catData[c]/total*100).toFixed(1) : 0;
html += '<tr>';
html += '<td style="display:flex;align-items:center;gap:6px"><span style="width:8px;height:8px;border-radius:50%;background:'+COLORS[i % COLORS.length]+';display:inline-block"></span>'+c+'</td>';
html += '<td style="color:var(--rose);font-family:\'JetBrains Mono\',monospace;font-size:0.8rem">'+catData[c].toFixed(2)+'€</td>';
html += '<td style="color:var(--text-dim);font-size:0.78rem">'+pct+'%</td>';
html += '</tr>';
});
html += '</table>';
document.getElementById('category-summary').innerHTML = html;
}
function updatePersonSummary() {
var filtered = getFilteredData();
var pers = {};
filtered.forEach(function(d) {
var p = d.prenom || 'Inconnu';
pers[p] = (pers[p]||0) + (parseFloat(d.montant)||0);
});
var total = Object.values(pers).reduce(function(a,b) { return a+b; }, 0);
var sorted = Object.keys(pers).sort(function(a,b) { return pers[b]-pers[a]; });
var html = '';
sorted.forEach(function(p, i) {
var pct = total > 0 ? (pers[p]/total*100) : 0;
html += '<div style="margin-bottom:12px">';
html += '<div style="display:flex;justify-content:space-between;margin-bottom:4px">';
html += '<span style="color:var(--text);font-size:0.85rem;display:flex;align-items:center;gap:6px">';
html += '<span style="width:8px;height:8px;border-radius:50%;background:'+COLORS[i % COLORS.length]+';display:inline-block"></span>'+p+'</span>';
html += '<span style="font-family:\'JetBrains Mono\',monospace;color:var(--violet);font-size:0.82rem">'+pers[p].toFixed(2)+'€</span>';
html += '</div>';
html += '<div class="prog-bar"><div class="prog-fill" style="width:'+pct.toFixed(1)+'%;background:'+COLORS[i % COLORS.length]+'"></div></div>';
html += '</div>';
});
if (!html) html = '<div style="color:var(--text-dim);font-size:0.85rem">Aucune donnée</div>';
document.getElementById('person-summary').innerHTML = html;
}
function updateTopExpenses() {
var filtered = getFilteredData();
var sorted = filtered.slice().sort(function(a,b) { return (parseFloat(b.montant)||0) - (parseFloat(a.montant)||0); });
var top = sorted.slice(0, 7);
var maxVal = top.length > 0 ? parseFloat(top[0].montant)||1 : 1;
var html = '';
top.forEach(function(d, i) {
var pct = ((parseFloat(d.montant)||0) / maxVal * 100).toFixed(0);
html += '<div style="margin-bottom:10px">';
html += '<div style="display:flex;justify-content:space-between;margin-bottom:4px;gap:8px">';
html += '<span style="color:var(--text);font-size:0.82rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1">'+i+'<span style="color:var(--text-dim)">.&nbsp;</span>'+(d.libelle||'—')+'</span>';
html += '<span style="font-family:\'JetBrains Mono\',monospace;color:var(--rose);font-size:0.78rem;white-space:nowrap">'+parseFloat(d.montant).toFixed(2)+'€</span>';
html += '</div>';
html += '<div class="prog-bar"><div class="prog-fill" style="width:'+pct+'%;background:'+COLORS[i % COLORS.length]+'"></div></div>';
html += '</div>';
});
if (!html) html = '<div style="color:var(--text-dim);font-size:0.85rem;text-align:center;padding:20px">Aucune dépense</div>';
document.getElementById('top-expenses').innerHTML = html;
}
function updateTrendChart() {
// Always show the last 6 months trend regardless of filter
var monthData = {};
depenses.forEach(function(d) {
if (!d.date) return;
var m = d.date.split('-')[0]+'-'+d.date.split('-')[1];
monthData[m] = (monthData[m]||0) + (parseFloat(d.montant)||0);
});
var months = Object.keys(monthData).sort().slice(-6);
var values = months.map(function(m) { return monthData[m]; });
var ctx = document.getElementById('trendChart').getContext('2d');
if (trendChart) trendChart.destroy();
trendChart = new Chart(ctx, {
type: 'line',
data: {
labels: months,
datasets: [{
label: 'Total mensuel',
data: values,
borderColor: '#a855f7',
backgroundColor: 'rgba(168,85,247,0.08)',
fill: true,
tension: 0.4,
pointBackgroundColor: '#a855f7',
pointRadius: 4,
borderWidth: 2,
}]
},
options: {
responsive: true, maintainAspectRatio: false,
plugins: { legend: { display: false }, tooltip: {
backgroundColor: 'rgba(15,23,42,0.95)',
borderColor: 'rgba(168,85,247,0.2)',
borderWidth: 1,
titleColor: '#94a3b8',
bodyColor: '#f1f5f9',
callbacks: { label: function(ctx) { return ' ' + ctx.raw.toFixed(2) + '€'; } }
}},
scales: {
y: { beginAtZero: true, grid: { color: 'rgba(100,116,139,0.08)' }, ticks: { color: '#64748b', font: { family: 'JetBrains Mono', size: 9 }, callback: function(v) { return v+'€'; } } },
x: { grid: { display: false }, ticks: { color: '#64748b', font: { size: 10 } } }
}
}
});
}
async function load() {
try {
var r = await fetch('api/depenses');
depenses = await r.json();
setFilter('month');
} catch(e) {
console.error('Erreur chargement:', e);
document.getElementById('kpi-total').textContent = 'Erreur';
}
}
load();
</script>
</body>
</html>

File diff suppressed because it is too large Load Diff