Initial commit
This commit is contained in:
29
.gitignore
vendored
Normal file
29
.gitignore
vendored
Normal file
@ -0,0 +1,29 @@
|
||||
# Python virtual environment
|
||||
venv/
|
||||
env/
|
||||
ENV/
|
||||
.env/
|
||||
.venv/
|
||||
|
||||
# Flask instance folder
|
||||
instance/
|
||||
|
||||
# Python cache files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# Distribution / packaging
|
||||
dist/
|
||||
build/
|
||||
*.egg-info/
|
||||
|
||||
# Local development settings
|
||||
.env
|
||||
.flaskenv
|
||||
|
||||
# IDE specific files
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
30
Dockerfile
Normal file
30
Dockerfile
Normal file
@ -0,0 +1,30 @@
|
||||
ARG BUILD_FROM
|
||||
FROM $BUILD_FROM
|
||||
|
||||
# Set shell
|
||||
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
|
||||
|
||||
# Install required packages
|
||||
RUN apk add --no-cache \
|
||||
python3 \
|
||||
py3-pip \
|
||||
py3-wheel \
|
||||
gcc \
|
||||
python3-dev \
|
||||
musl-dev
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy files
|
||||
COPY requirements.txt .
|
||||
COPY . .
|
||||
|
||||
# Install Python requirements
|
||||
RUN pip3 install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Expose the port used for the web interface
|
||||
EXPOSE 8099
|
||||
|
||||
# Start the app
|
||||
CMD ["python3", "main.py"]
|
||||
95
README.md
Normal file
95
README.md
Normal file
@ -0,0 +1,95 @@
|
||||
# Expense Tracker Add-on für Home Assistant
|
||||
|
||||
Dieses Home Assistant Add-on bietet eine komfortable Möglichkeit, persönliche oder Haushaltsausgaben direkt innerhalb von Home Assistant zu verfolgen und zu analysieren.
|
||||
|
||||
## Features
|
||||
|
||||
- **Erfassung von Ausgaben**: Erfasse manuell oder automatisiert via API deine täglichen, wöchentlichen oder monatlichen Ausgaben.
|
||||
- **Kategorisierung**: Ordne Ausgaben in selbst definierbare Kategorien wie Lebensmittel, Transport, Freizeit, etc., um einen besseren Überblick über dein Budget zu erhalten.
|
||||
- **Integration in Dashboards**: Binde Ausgabendiagramme und Statistiken direkt in deine Home Assistant Dashboards ein.
|
||||
- **Datensicherheit**: Alle Daten bleiben lokal in deinem Home Assistant-System und werden nicht an Dritte weitergegeben.
|
||||
|
||||
## Installation
|
||||
|
||||
1. Navigiere in Home Assistant zu **Einstellungen** → **Add-ons** → **Add-on Store**
|
||||
2. Klicke auf die drei Punkte in der oberen rechten Ecke und wähle **Repositories**
|
||||
3. Füge die URL dieses Repositories hinzu
|
||||
4. Suche nach "Expense Tracker" in der Add-on Liste und installiere es
|
||||
5. Starte das Add-on und aktiviere "Show in sidebar"
|
||||
|
||||
## Verwendung
|
||||
|
||||
### Ausgaben erfassen
|
||||
|
||||
1. Öffne das Add-on über die Seitenleiste
|
||||
2. Navigiere zum Tab "Ausgaben"
|
||||
3. Fülle das Formular aus mit:
|
||||
- Betrag
|
||||
- Beschreibung
|
||||
- Kategorie
|
||||
- Datum
|
||||
4. Klicke auf "Ausgabe speichern"
|
||||
|
||||
### Kategorien verwalten
|
||||
|
||||
1. Navigiere zum Tab "Kategorien"
|
||||
2. Füge neue Kategorien hinzu oder sieh dir bestehende an
|
||||
|
||||
### Berichte anzeigen
|
||||
|
||||
1. Navigiere zum Tab "Berichte"
|
||||
2. Filtere nach Zeitraum, um spezifische Daten anzuzeigen
|
||||
3. Betrachte die Ausgabenverteilung nach Kategorien und den zeitlichen Verlauf
|
||||
|
||||
## API-Integration
|
||||
|
||||
Das Add-on bietet eine REST-API zur Integration mit anderen Systemen:
|
||||
|
||||
### Ausgaben hinzufügen
|
||||
|
||||
```
|
||||
POST /api/expenses
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"amount": 12.99,
|
||||
"description": "Mittagessen",
|
||||
"category_id": 1,
|
||||
"date": "2025-05-30T12:00:00"
|
||||
}
|
||||
```
|
||||
|
||||
### Ausgaben abrufen
|
||||
|
||||
```
|
||||
GET /api/expenses
|
||||
```
|
||||
|
||||
### Kategorien abrufen
|
||||
|
||||
```
|
||||
GET /api/categories
|
||||
```
|
||||
|
||||
## Dashboard-Integration
|
||||
|
||||
Um Expense Tracker-Widgets in dein Home Assistant Dashboard einzubinden, kannst du die iframe-Karte verwenden:
|
||||
|
||||
```yaml
|
||||
type: iframe
|
||||
url: /api/hassio_ingress/self_slug
|
||||
aspect_ratio: 75%
|
||||
title: Ausgaben
|
||||
```
|
||||
|
||||
## Daten-Backup
|
||||
|
||||
Die Datenbank wird im `/data`-Verzeichnis des Add-ons gespeichert und wird automatisch in den regulären Home Assistant-Backups gesichert.
|
||||
|
||||
## Support
|
||||
|
||||
Bei Fragen oder Problemen öffne bitte ein Issue im GitHub-Repository.
|
||||
|
||||
## Lizenz
|
||||
|
||||
Dieses Projekt ist unter der MIT-Lizenz veröffentlicht.
|
||||
23
config.yaml
Normal file
23
config.yaml
Normal file
@ -0,0 +1,23 @@
|
||||
name: "Expense Tracker"
|
||||
description: "Track and analyze personal or household expenses within Home Assistant"
|
||||
version: "1.0.0"
|
||||
slug: "expense_tracker"
|
||||
init: false
|
||||
arch:
|
||||
- aarch64
|
||||
- amd64
|
||||
- armhf
|
||||
- armv7
|
||||
- i386
|
||||
startup: application
|
||||
ingress: true
|
||||
ingress_port: 8099
|
||||
panel_icon: mdi:cash-multiple
|
||||
panel_title: "Expense Tracker"
|
||||
panel_admin: true
|
||||
options:
|
||||
database_path: "/data/expense_tracker.db"
|
||||
log_level: "info"
|
||||
schema:
|
||||
database_path: str
|
||||
log_level: list(trace|debug|info|warning|error|fatal)
|
||||
276
main.py
Normal file
276
main.py
Normal file
@ -0,0 +1,276 @@
|
||||
#!/usr/bin/env python3
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
import sqlite3
|
||||
from datetime import datetime
|
||||
from flask import Flask, request, jsonify, render_template, redirect, url_for
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
import plotly.express as px
|
||||
import pandas as pd
|
||||
|
||||
# Setup logging
|
||||
logging.basicConfig(
|
||||
level=os.environ.get("LOG_LEVEL", "INFO"),
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
)
|
||||
logger = logging.getLogger("expense_tracker")
|
||||
|
||||
# Load configuration
|
||||
try:
|
||||
with open("/data/options.json", "r") as options_file:
|
||||
config = json.load(options_file)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load options.json, using defaults: {e}")
|
||||
config = {
|
||||
"database_path": "expense_tracker.db", # Local file in current directory
|
||||
"log_level": "info",
|
||||
}
|
||||
|
||||
# Set log level from config
|
||||
log_level = getattr(logging, config["log_level"].upper(), logging.INFO)
|
||||
logger.setLevel(log_level)
|
||||
|
||||
# Initialize Flask app
|
||||
app = Flask(__name__)
|
||||
app.config["SECRET_KEY"] = os.urandom(24)
|
||||
app.config["SQLALCHEMY_DATABASE_URI"] = f"sqlite:///{config['database_path']}"
|
||||
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
|
||||
|
||||
# Initialize database
|
||||
db = SQLAlchemy(app)
|
||||
|
||||
# Define database models
|
||||
class Category(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(50), nullable=False, unique=True)
|
||||
description = db.Column(db.String(200))
|
||||
expenses = db.relationship("Expense", backref="category", lazy=True)
|
||||
|
||||
class Expense(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
amount = db.Column(db.Float, nullable=False)
|
||||
description = db.Column(db.String(200))
|
||||
date = db.Column(db.DateTime, nullable=False, default=datetime.now)
|
||||
category_id = db.Column(db.Integer, db.ForeignKey("category.id"), nullable=False)
|
||||
|
||||
# Create database tables
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
|
||||
# Add default categories if none exist
|
||||
if Category.query.count() == 0:
|
||||
default_categories = [
|
||||
Category(name="Lebensmittel", description="Ausgaben für Lebensmittel und Getränke"),
|
||||
Category(name="Transport", description="Ausgaben für öffentliche Verkehrsmittel, Taxi, etc."),
|
||||
Category(name="Freizeit", description="Ausgaben für Unterhaltung, Hobbys, etc."),
|
||||
Category(name="Wohnen", description="Miete, Nebenkosten, Reparaturen"),
|
||||
Category(name="Gesundheit", description="Medikamente, Arztbesuche"),
|
||||
Category(name="Sonstiges", description="Sonstige Ausgaben")
|
||||
]
|
||||
for category in default_categories:
|
||||
db.session.add(category)
|
||||
db.session.commit()
|
||||
logger.info("Added default categories")
|
||||
|
||||
# Routes
|
||||
@app.route("/")
|
||||
def index():
|
||||
categories = Category.query.all()
|
||||
expenses = Expense.query.order_by(Expense.date.desc()).limit(10).all()
|
||||
|
||||
# Calculate total expenses
|
||||
total_expenses = db.session.query(db.func.sum(Expense.amount)).scalar() or 0
|
||||
|
||||
# Calculate expenses by category
|
||||
category_expenses = db.session.query(
|
||||
Category.name, db.func.sum(Expense.amount)
|
||||
).join(Expense).group_by(Category.name).all()
|
||||
|
||||
# Prepare data for charts
|
||||
category_names = [item[0] for item in category_expenses]
|
||||
category_amounts = [float(item[1]) for item in category_expenses]
|
||||
|
||||
return render_template(
|
||||
"index.html",
|
||||
categories=categories,
|
||||
expenses=expenses,
|
||||
total_expenses=total_expenses,
|
||||
category_names=json.dumps(category_names),
|
||||
category_amounts=json.dumps(category_amounts),
|
||||
now=datetime.now()
|
||||
)
|
||||
|
||||
@app.route("/expenses", methods=["GET", "POST"])
|
||||
def expenses():
|
||||
if request.method == "POST":
|
||||
# Add new expense
|
||||
amount = float(request.form["amount"])
|
||||
description = request.form["description"]
|
||||
category_id = int(request.form["category_id"])
|
||||
date_str = request.form["date"]
|
||||
|
||||
# Parse date from form
|
||||
date = datetime.strptime(date_str, "%Y-%m-%d")
|
||||
|
||||
# Create new expense
|
||||
expense = Expense(
|
||||
amount=amount,
|
||||
description=description,
|
||||
category_id=category_id,
|
||||
date=date
|
||||
)
|
||||
|
||||
db.session.add(expense)
|
||||
db.session.commit()
|
||||
|
||||
return redirect(url_for("expenses"))
|
||||
|
||||
# GET request - show expenses and form
|
||||
categories = Category.query.all()
|
||||
expenses = Expense.query.order_by(Expense.date.desc()).all()
|
||||
|
||||
return render_template(
|
||||
"expenses.html",
|
||||
categories=categories,
|
||||
expenses=expenses,
|
||||
today=datetime.now().strftime("%Y-%m-%d")
|
||||
)
|
||||
|
||||
@app.route("/categories", methods=["GET", "POST"])
|
||||
def categories():
|
||||
if request.method == "POST":
|
||||
# Add new category
|
||||
name = request.form["name"]
|
||||
description = request.form["description"]
|
||||
|
||||
# Check if category already exists
|
||||
existing = Category.query.filter_by(name=name).first()
|
||||
if existing:
|
||||
return render_template(
|
||||
"categories.html",
|
||||
categories=Category.query.all(),
|
||||
error="Kategorie existiert bereits"
|
||||
)
|
||||
|
||||
# Create new category
|
||||
category = Category(name=name, description=description)
|
||||
db.session.add(category)
|
||||
db.session.commit()
|
||||
|
||||
return redirect(url_for("categories"))
|
||||
|
||||
# GET request - show categories and form
|
||||
categories = Category.query.all()
|
||||
return render_template("categories.html", categories=categories)
|
||||
|
||||
@app.route("/api/expenses", methods=["GET", "POST"])
|
||||
def api_expenses():
|
||||
if request.method == "POST":
|
||||
# Add new expense via API
|
||||
data = request.json
|
||||
|
||||
if not data or "amount" not in data or "category_id" not in data:
|
||||
return jsonify({"error": "Invalid data"}), 400
|
||||
|
||||
# Create new expense
|
||||
expense = Expense(
|
||||
amount=float(data["amount"]),
|
||||
description=data.get("description", ""),
|
||||
category_id=int(data["category_id"]),
|
||||
date=datetime.now() if "date" not in data else datetime.fromisoformat(data["date"])
|
||||
)
|
||||
|
||||
db.session.add(expense)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({"id": expense.id, "status": "success"}), 201
|
||||
|
||||
# GET request - return expenses as JSON
|
||||
expenses = Expense.query.order_by(Expense.date.desc()).all()
|
||||
result = []
|
||||
|
||||
for expense in expenses:
|
||||
result.append({
|
||||
"id": expense.id,
|
||||
"amount": expense.amount,
|
||||
"description": expense.description,
|
||||
"date": expense.date.isoformat(),
|
||||
"category_id": expense.category_id,
|
||||
"category_name": expense.category.name
|
||||
})
|
||||
|
||||
return jsonify(result)
|
||||
|
||||
@app.route("/api/categories", methods=["GET"])
|
||||
def api_categories():
|
||||
categories = Category.query.all()
|
||||
result = []
|
||||
|
||||
for category in categories:
|
||||
result.append({
|
||||
"id": category.id,
|
||||
"name": category.name,
|
||||
"description": category.description
|
||||
})
|
||||
|
||||
return jsonify(result)
|
||||
|
||||
@app.route("/reports")
|
||||
def reports():
|
||||
# Get date range filter
|
||||
start_date = request.args.get("start_date")
|
||||
end_date = request.args.get("end_date")
|
||||
|
||||
query = db.session.query(
|
||||
Expense.date,
|
||||
Category.name.label("category"),
|
||||
Expense.amount
|
||||
).join(Category)
|
||||
|
||||
# Apply date filters if provided
|
||||
if start_date:
|
||||
query = query.filter(Expense.date >= datetime.strptime(start_date, "%Y-%m-%d"))
|
||||
if end_date:
|
||||
query = query.filter(Expense.date <= datetime.strptime(end_date, "%Y-%m-%d"))
|
||||
|
||||
# Execute query and convert to DataFrame
|
||||
expenses_data = query.all()
|
||||
df = pd.DataFrame(expenses_data, columns=["date", "category", "amount"])
|
||||
|
||||
# Generate charts
|
||||
if not df.empty:
|
||||
# Expenses by category pie chart
|
||||
fig_category = px.pie(
|
||||
df,
|
||||
values="amount",
|
||||
names="category",
|
||||
title="Ausgaben nach Kategorie"
|
||||
)
|
||||
category_chart = fig_category.to_html(full_html=False)
|
||||
|
||||
# Expenses over time line chart
|
||||
df["date"] = pd.to_datetime(df["date"])
|
||||
df_by_date = df.groupby([df["date"].dt.date]).sum().reset_index()
|
||||
fig_timeline = px.line(
|
||||
df_by_date,
|
||||
x="date",
|
||||
y="amount",
|
||||
title="Ausgaben im Zeitverlauf"
|
||||
)
|
||||
timeline_chart = fig_timeline.to_html(full_html=False)
|
||||
else:
|
||||
category_chart = "<p>Keine Daten verfügbar</p>"
|
||||
timeline_chart = "<p>Keine Daten verfügbar</p>"
|
||||
|
||||
return render_template(
|
||||
"reports.html",
|
||||
category_chart=category_chart,
|
||||
timeline_chart=timeline_chart,
|
||||
start_date=start_date or "",
|
||||
end_date=end_date or ""
|
||||
)
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Start the Flask application
|
||||
app.run(host="0.0.0.0", port=8099)
|
||||
7
requirements.txt
Normal file
7
requirements.txt
Normal file
@ -0,0 +1,7 @@
|
||||
flask==2.3.3
|
||||
flask-sqlalchemy==3.0.5
|
||||
paho-mqtt==2.2.1
|
||||
plotly==5.16.1
|
||||
pandas==2.0.3
|
||||
werkzeug==2.3.7
|
||||
gunicorn==21.2.0
|
||||
12
run.sh
Executable file
12
run.sh
Executable file
@ -0,0 +1,12 @@
|
||||
#!/usr/bin/with-contenv bashio
|
||||
|
||||
# Set up environment variables
|
||||
export LOG_LEVEL=$(bashio::config 'log_level')
|
||||
export DATABASE_PATH=$(bashio::config 'database_path')
|
||||
|
||||
# Ensure data directory exists
|
||||
mkdir -p "$(dirname "$DATABASE_PATH")"
|
||||
|
||||
# Start the Flask application with gunicorn
|
||||
cd /app
|
||||
exec gunicorn --bind 0.0.0.0:8099 --workers 2 --threads 4 main:app
|
||||
303
static/css/styles.css
Normal file
303
static/css/styles.css
Normal file
@ -0,0 +1,303 @@
|
||||
:root {
|
||||
--primary-color: #03a9f4;
|
||||
--secondary-color: #4caf50;
|
||||
--background-color: #f5f5f5;
|
||||
--card-background: #ffffff;
|
||||
--text-color: #212121;
|
||||
--text-secondary: #757575;
|
||||
--border-color: #e0e0e0;
|
||||
--sidebar-width: 250px;
|
||||
--header-height: 60px;
|
||||
--shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Roboto', 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background-color: var(--background-color);
|
||||
color: var(--text-color);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Sidebar */
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
width: var(--sidebar-width);
|
||||
height: 100vh;
|
||||
background-color: var(--card-background);
|
||||
box-shadow: var(--shadow);
|
||||
z-index: 100;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.sidebar-header i {
|
||||
font-size: 24px;
|
||||
color: var(--primary-color);
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.sidebar-header h2 {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.sidebar nav ul {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.sidebar nav ul li a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 15px 20px;
|
||||
color: var(--text-color);
|
||||
text-decoration: none;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.sidebar nav ul li a:hover {
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.sidebar nav ul li a i {
|
||||
margin-right: 10px;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
/* Main Content */
|
||||
.main-content {
|
||||
margin-left: var(--sidebar-width);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Dashboard */
|
||||
.dashboard h1, .expenses-page h1, .categories-page h1, .reports-page h1 {
|
||||
margin-bottom: 20px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.dashboard-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
background-color: var(--card-background);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
box-shadow: var(--shadow);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 50%;
|
||||
background-color: rgba(3, 169, 244, 0.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 15px;
|
||||
}
|
||||
|
||||
.card-icon i {
|
||||
font-size: 24px;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.card-content h3 {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 5px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.card-content p {
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.card-content p.amount {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
/* Charts */
|
||||
.dashboard-charts, .charts-container {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.chart-container, .chart-box {
|
||||
background-color: var(--card-background);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
box-shadow: var(--shadow);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.chart-container h2, .chart-box h2 {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
table th, table td {
|
||||
padding: 12px 15px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
table th {
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
table td.amount {
|
||||
font-weight: 500;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
table td.no-data {
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
padding: 30px 0;
|
||||
}
|
||||
|
||||
/* Forms */
|
||||
.form-container {
|
||||
background-color: var(--card-background);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
box-shadow: var(--shadow);
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.form-container h2 {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.form-group.inline {
|
||||
display: inline-block;
|
||||
margin-right: 15px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-group input, .form-group select {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.button {
|
||||
display: inline-block;
|
||||
padding: 10px 15px;
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
background-color: #0288d1;
|
||||
}
|
||||
|
||||
.button.secondary {
|
||||
background-color: #9e9e9e;
|
||||
}
|
||||
|
||||
.button.secondary:hover {
|
||||
background-color: #757575;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
margin-top: 15px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Error messages */
|
||||
.error-message {
|
||||
background-color: #ffebee;
|
||||
color: #c62828;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
/* Filter container */
|
||||
.filter-container {
|
||||
background-color: var(--card-background);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
box-shadow: var(--shadow);
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.filter-container h2 {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
/* Responsive design */
|
||||
@media (max-width: 768px) {
|
||||
.sidebar {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.dashboard-summary {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
43
static/js/scripts.js
Normal file
43
static/js/scripts.js
Normal file
@ -0,0 +1,43 @@
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Add current date to the dashboard
|
||||
const now = new Date();
|
||||
const months = [
|
||||
'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
|
||||
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'
|
||||
];
|
||||
|
||||
// Format date for display
|
||||
const formattedDate = `${months[now.getMonth()]} ${now.getFullYear()}`;
|
||||
|
||||
// Add to any elements with date-display class
|
||||
const dateElements = document.querySelectorAll('.date-display');
|
||||
dateElements.forEach(element => {
|
||||
element.textContent = formattedDate;
|
||||
});
|
||||
|
||||
// Handle form validation
|
||||
const forms = document.querySelectorAll('form');
|
||||
forms.forEach(form => {
|
||||
form.addEventListener('submit', function(event) {
|
||||
const amountInput = form.querySelector('input[name="amount"]');
|
||||
if (amountInput && parseFloat(amountInput.value) <= 0) {
|
||||
event.preventDefault();
|
||||
alert('Bitte geben Sie einen gültigen Betrag ein (größer als 0).');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Add responsive menu toggle for mobile
|
||||
const menuToggle = document.createElement('button');
|
||||
menuToggle.classList.add('menu-toggle');
|
||||
menuToggle.innerHTML = '<i class="fas fa-bars"></i>';
|
||||
|
||||
const sidebar = document.querySelector('.sidebar');
|
||||
if (sidebar) {
|
||||
sidebar.parentNode.insertBefore(menuToggle, sidebar);
|
||||
|
||||
menuToggle.addEventListener('click', function() {
|
||||
sidebar.classList.toggle('active');
|
||||
});
|
||||
}
|
||||
});
|
||||
35
templates/base.html
Normal file
35
templates/base.html
Normal file
@ -0,0 +1,35 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Expense Tracker - Home Assistant</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
|
||||
<script src="https://cdn.plot.ly/plotly-latest.min.js"></script>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<i class="fas fa-cash-register"></i>
|
||||
<h2>Expense Tracker</h2>
|
||||
</div>
|
||||
<nav>
|
||||
<ul>
|
||||
<li><a href="{{ url_for('index') }}"><i class="fas fa-home"></i> Dashboard</a></li>
|
||||
<li><a href="{{ url_for('expenses') }}"><i class="fas fa-receipt"></i> Ausgaben</a></li>
|
||||
<li><a href="{{ url_for('categories') }}"><i class="fas fa-tags"></i> Kategorien</a></li>
|
||||
<!--<li><a href="{{ url_for('reports') }}"><i class="fas fa-chart-pie"></i> Berichte</a></li>-->
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div class="main-content">
|
||||
<div class="content-wrapper">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="{{ url_for('static', filename='js/scripts.js') }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
53
templates/categories.html
Normal file
53
templates/categories.html
Normal file
@ -0,0 +1,53 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="categories-page">
|
||||
<h1>Kategorien verwalten</h1>
|
||||
|
||||
<div class="form-container">
|
||||
<h2>Neue Kategorie hinzufügen</h2>
|
||||
{% if error %}
|
||||
<div class="error-message">{{ error }}</div>
|
||||
{% endif %}
|
||||
<form action="{{ url_for('categories') }}" method="post">
|
||||
<div class="form-group">
|
||||
<label for="name">Name:</label>
|
||||
<input type="text" id="name" name="name" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="description">Beschreibung:</label>
|
||||
<input type="text" id="description" name="description">
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="button primary">Kategorie speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="categories-list">
|
||||
<h2>Alle Kategorien</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Beschreibung</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for category in categories %}
|
||||
<tr>
|
||||
<td>{{ category.name }}</td>
|
||||
<td>{{ category.description }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="2" class="no-data">Keine Kategorien vorhanden</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
69
templates/expenses.html
Normal file
69
templates/expenses.html
Normal file
@ -0,0 +1,69 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="expenses-page">
|
||||
<h1>Ausgaben verwalten</h1>
|
||||
|
||||
<div class="form-container">
|
||||
<h2>Neue Ausgabe hinzufügen</h2>
|
||||
<form action="{{ url_for('expenses') }}" method="post">
|
||||
<div class="form-group">
|
||||
<label for="amount">Betrag (€):</label>
|
||||
<input type="number" id="amount" name="amount" step="0.01" min="0.01" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="description">Beschreibung:</label>
|
||||
<input type="text" id="description" name="description" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="category_id">Kategorie:</label>
|
||||
<select id="category_id" name="category_id" required>
|
||||
<option value="">-- Kategorie wählen --</option>
|
||||
{% for category in categories %}
|
||||
<option value="{{ category.id }}">{{ category.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="date">Datum:</label>
|
||||
<input type="date" id="date" name="date" value="{{ today }}" required>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="button primary">Ausgabe speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="expenses-list">
|
||||
<h2>Alle Ausgaben</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Datum</th>
|
||||
<th>Beschreibung</th>
|
||||
<th>Kategorie</th>
|
||||
<th>Betrag</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for expense in expenses %}
|
||||
<tr>
|
||||
<td>{{ expense.date.strftime('%d.%m.%Y') }}</td>
|
||||
<td>{{ expense.description }}</td>
|
||||
<td>{{ expense.category.name }}</td>
|
||||
<td class="amount">{{ "%.2f"|format(expense.amount) }} €</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="4" class="no-data">Keine Ausgaben vorhanden</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
110
templates/index.html
Normal file
110
templates/index.html
Normal file
@ -0,0 +1,110 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="dashboard">
|
||||
<h1>Dashboard</h1>
|
||||
|
||||
<div class="dashboard-summary">
|
||||
<div class="summary-card">
|
||||
<div class="card-icon">
|
||||
<i class="fas fa-euro-sign"></i>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<h3>Gesamtausgaben</h3>
|
||||
<p class="amount">{{ "%.2f"|format(total_expenses) }} €</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="summary-card">
|
||||
<div class="card-icon">
|
||||
<i class="fas fa-calendar-alt"></i>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<h3>Aktuelle Periode</h3>
|
||||
<p>{{ now.strftime('%B %Y') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="summary-card">
|
||||
<div class="card-icon">
|
||||
<i class="fas fa-tags"></i>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<h3>Kategorien</h3>
|
||||
<p>{{ categories|length }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-charts">
|
||||
<div class="chart-container">
|
||||
<h2>Ausgaben nach Kategorie</h2>
|
||||
<div id="category-chart"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="recent-expenses">
|
||||
<h2>Neueste Ausgaben</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Datum</th>
|
||||
<th>Beschreibung</th>
|
||||
<th>Kategorie</th>
|
||||
<th>Betrag</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for expense in expenses %}
|
||||
<tr>
|
||||
<td>{{ expense.date.strftime('%d.%m.%Y') }}</td>
|
||||
<td>{{ expense.description }}</td>
|
||||
<td>{{ expense.category.name }}</td>
|
||||
<td class="amount">{{ "%.2f"|format(expense.amount) }} €</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="4" class="no-data">Keine Ausgaben vorhanden</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="action-button">
|
||||
<a href="{{ url_for('expenses') }}" class="button">Alle Ausgaben anzeigen</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Category pie chart
|
||||
const categoryNames = {{ category_names|safe }};
|
||||
const categoryAmounts = {{ category_amounts|safe }};
|
||||
|
||||
if (categoryNames.length > 0) {
|
||||
const data = [{
|
||||
values: categoryAmounts,
|
||||
labels: categoryNames,
|
||||
type: 'pie',
|
||||
marker: {
|
||||
colors: [
|
||||
'#4e79a7', '#f28e2c', '#e15759', '#76b7b2',
|
||||
'#59a14f', '#edc949', '#af7aa1', '#ff9da7',
|
||||
'#9c755f', '#bab0ab'
|
||||
]
|
||||
}
|
||||
}];
|
||||
|
||||
const layout = {
|
||||
height: 400,
|
||||
margin: { t: 0, b: 0, l: 0, r: 0 },
|
||||
showlegend: true
|
||||
};
|
||||
|
||||
Plotly.newPlot('category-chart', data, layout);
|
||||
} else {
|
||||
document.getElementById('category-chart').innerHTML = '<p class="no-data">Keine Daten verfügbar</p>';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
43
templates/reports.html
Normal file
43
templates/reports.html
Normal file
@ -0,0 +1,43 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="reports-page">
|
||||
<h1>Ausgabenberichte</h1>
|
||||
|
||||
<div class="filter-container">
|
||||
<h2>Zeitraum filtern</h2>
|
||||
<form action="{{ url_for('reports') }}" method="get">
|
||||
<div class="form-group inline">
|
||||
<label for="start_date">Von:</label>
|
||||
<input type="date" id="start_date" name="start_date" value="{{ start_date }}">
|
||||
</div>
|
||||
|
||||
<div class="form-group inline">
|
||||
<label for="end_date">Bis:</label>
|
||||
<input type="date" id="end_date" name="end_date" value="{{ end_date }}">
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="button primary">Filter anwenden</button>
|
||||
<a href="{{ url_for('reports') }}" class="button secondary">Zurücksetzen</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="charts-container">
|
||||
<div class="chart-box">
|
||||
<h2>Ausgaben nach Kategorie</h2>
|
||||
<div class="chart">
|
||||
{{ category_chart|safe }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chart-box">
|
||||
<h2>Ausgaben im Zeitverlauf</h2>
|
||||
<div class="chart">
|
||||
{{ timeline_chart|safe }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user