Compare commits
10 Commits
d3cb9b06aa
...
7f0688d00a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f0688d00a | ||
|
|
6d3ef9dec3 | ||
|
|
d3b9f1c664 | ||
|
|
83a850d520 | ||
|
|
31e5a6bc29 | ||
|
|
38616e6823 | ||
|
|
b40bf56c97 | ||
|
|
64c7a976f7 | ||
|
|
47e4648a03 | ||
|
|
d507e4e0a6 |
157
DOCUMENTATION.md
Normal file
157
DOCUMENTATION.md
Normal 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*
|
||||
BIN
__pycache__/app.cpython-312.pyc
Normal file
BIN
__pycache__/app.cpython-312.pyc
Normal file
Binary file not shown.
337
app.py
337
app.py
@@ -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})
|
||||
|
||||
# Generate text
|
||||
# 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)
|
||||
|
||||
@@ -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
BIN
depenses.db
Normal file
Binary file not shown.
731
templates/dashboard.html
Normal file
731
templates/dashboard.html
Normal 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)">. </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>
|
||||
1479
templates/index.html
1479
templates/index.html
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user