nowe funkcja statystyk i poprawki
This commit is contained in:
85
app.py
85
app.py
@@ -125,6 +125,7 @@ class Expense(db.Model):
|
||||
amount = db.Column(db.Float, nullable=False)
|
||||
added_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
receipt_filename = db.Column(db.String(255), nullable=True)
|
||||
list = db.relationship("ShoppingList", backref="expenses", lazy=True)
|
||||
|
||||
|
||||
with app.app_context():
|
||||
@@ -614,7 +615,7 @@ def logout():
|
||||
@login_required
|
||||
def create_list():
|
||||
title = request.form.get("title")
|
||||
is_temporary = "temporary" in request.form
|
||||
is_temporary = request.form.get("temporary") == "1"
|
||||
token = generate_share_token(8)
|
||||
expires_at = datetime.utcnow() + timedelta(days=7) if is_temporary else None
|
||||
new_list = ShoppingList(
|
||||
@@ -654,6 +655,76 @@ def view_list(list_id):
|
||||
)
|
||||
|
||||
|
||||
@app.route("/user_expenses")
|
||||
@login_required
|
||||
def user_expenses():
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
expenses = (
|
||||
Expense.query
|
||||
.join(ShoppingList, Expense.list_id == ShoppingList.id)
|
||||
.options(joinedload(Expense.list))
|
||||
.filter(ShoppingList.owner_id == current_user.id)
|
||||
.order_by(Expense.added_at.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
rows = [
|
||||
{
|
||||
"title": e.list.title if e.list else "Nieznana",
|
||||
"amount": e.amount,
|
||||
"added_at": e.added_at
|
||||
}
|
||||
for e in expenses
|
||||
]
|
||||
|
||||
return render_template("user_expenses.html", expense_table=rows)
|
||||
|
||||
|
||||
|
||||
@app.route("/user/expenses_data")
|
||||
@login_required
|
||||
def user_expenses_data():
|
||||
range_type = request.args.get("range", "monthly")
|
||||
start_date = request.args.get("start_date")
|
||||
end_date = request.args.get("end_date")
|
||||
|
||||
query = (
|
||||
Expense.query
|
||||
.join(ShoppingList, Expense.list_id == ShoppingList.id)
|
||||
.filter(ShoppingList.owner_id == current_user.id)
|
||||
)
|
||||
|
||||
if start_date and end_date:
|
||||
try:
|
||||
start = datetime.strptime(start_date, "%Y-%m-%d")
|
||||
end = datetime.strptime(end_date, "%Y-%m-%d") + timedelta(days=1)
|
||||
query = query.filter(Expense.timestamp >= start, Expense.timestamp < end)
|
||||
except ValueError:
|
||||
return jsonify({"error": "Błędne daty"}), 400
|
||||
|
||||
expenses = query.all()
|
||||
|
||||
grouped = defaultdict(float)
|
||||
for e in expenses:
|
||||
ts = e.added_at or datetime.utcnow()
|
||||
if range_type == "monthly":
|
||||
key = ts.strftime("%Y-%m")
|
||||
elif range_type == "quarterly":
|
||||
key = f"{ts.year}-Q{((ts.month - 1) // 3) + 1}"
|
||||
elif range_type == "halfyearly":
|
||||
key = f"{ts.year}-H{1 if ts.month <= 6 else 2}"
|
||||
elif range_type == "yearly":
|
||||
key = str(ts.year)
|
||||
else:
|
||||
key = ts.strftime("%Y-%m-%d")
|
||||
grouped[key] += e.amount
|
||||
|
||||
labels = sorted(grouped)
|
||||
data = [round(grouped[label], 2) for label in labels]
|
||||
return jsonify({"labels": labels, "expenses": data})
|
||||
|
||||
|
||||
@app.route("/share/<token>")
|
||||
@app.route("/guest-list/<int:list_id>")
|
||||
def shared_list(token=None, list_id=None):
|
||||
@@ -1095,7 +1166,6 @@ def edit_list(list_id):
|
||||
users = User.query.all()
|
||||
items = Item.query.filter_by(list_id=list_id).order_by(Item.id.desc()).all()
|
||||
|
||||
# Pobranie listy plików paragonów
|
||||
receipt_pattern = f"list_{list_id}_"
|
||||
all_files = os.listdir(app.config["UPLOAD_FOLDER"])
|
||||
receipts = [f for f in all_files if f.startswith(receipt_pattern)]
|
||||
@@ -1108,6 +1178,8 @@ def edit_list(list_id):
|
||||
new_amount_str = request.form.get("amount")
|
||||
is_archived = "archived" in request.form
|
||||
is_public = "public" in request.form
|
||||
is_temporary = "temporary" in request.form
|
||||
expires_at_raw = request.form.get("expires_at")
|
||||
new_owner_id = request.form.get("owner_id")
|
||||
|
||||
if new_title:
|
||||
@@ -1115,6 +1187,15 @@ def edit_list(list_id):
|
||||
|
||||
l.is_archived = is_archived
|
||||
l.is_public = is_public
|
||||
l.is_temporary = is_temporary
|
||||
|
||||
if expires_at_raw:
|
||||
try:
|
||||
l.expires_at = datetime.strptime(expires_at_raw, "%Y-%m-%dT%H:%M")
|
||||
except ValueError:
|
||||
l.expires_at = None
|
||||
else:
|
||||
l.expires_at = None
|
||||
|
||||
if new_owner_id:
|
||||
try:
|
||||
|
90
static/js/user_expenses.js
Normal file
90
static/js/user_expenses.js
Normal file
@@ -0,0 +1,90 @@
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
let expensesChart = null;
|
||||
const rangeLabel = document.getElementById("chartRangeLabel");
|
||||
|
||||
function loadExpenses(range = "monthly", startDate = null, endDate = null) {
|
||||
let url = '/user/expenses_data?range=' + range;
|
||||
if (startDate && endDate) {
|
||||
url += `&start_date=${startDate}&end_date=${endDate}`;
|
||||
}
|
||||
|
||||
fetch(url, { cache: "no-store" })
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
const ctx = document.getElementById('expensesChart').getContext('2d');
|
||||
|
||||
if (expensesChart) {
|
||||
expensesChart.destroy();
|
||||
}
|
||||
|
||||
expensesChart = new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: data.labels,
|
||||
datasets: [{
|
||||
label: 'Suma wydatków [PLN]',
|
||||
data: data.expenses,
|
||||
backgroundColor: '#0d6efd'
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (startDate && endDate) {
|
||||
rangeLabel.textContent = `Widok: własny zakres (${startDate} → ${endDate})`;
|
||||
} else {
|
||||
let labelText = "";
|
||||
if (range === "monthly") labelText = "Widok: miesięczne";
|
||||
else if (range === "quarterly") labelText = "Widok: kwartalne";
|
||||
else if (range === "halfyearly") labelText = "Widok: półroczne";
|
||||
else if (range === "yearly") labelText = "Widok: roczne";
|
||||
rangeLabel.textContent = labelText;
|
||||
}
|
||||
|
||||
})
|
||||
.catch(error => {
|
||||
console.error("Błąd pobierania danych:", error);
|
||||
});
|
||||
}
|
||||
|
||||
// Inicjalizacja zakresu dat
|
||||
const startDateInput = document.getElementById("startDate");
|
||||
const endDateInput = document.getElementById("endDate");
|
||||
const today = new Date();
|
||||
const lastWeek = new Date(today);
|
||||
lastWeek.setDate(today.getDate() - 7);
|
||||
const formatDate = (d) => d.toISOString().split('T')[0];
|
||||
startDateInput.value = formatDate(lastWeek);
|
||||
endDateInput.value = formatDate(today);
|
||||
|
||||
// Załaduj początkowy widok
|
||||
loadExpenses();
|
||||
|
||||
// Przycisk własnego zakresu
|
||||
document.getElementById('customRangeBtn').addEventListener('click', function () {
|
||||
const startDate = startDateInput.value;
|
||||
const endDate = endDateInput.value;
|
||||
if (startDate && endDate) {
|
||||
document.querySelectorAll('.range-btn').forEach(b => b.classList.remove('active'));
|
||||
loadExpenses('custom', startDate, endDate);
|
||||
} else {
|
||||
alert("Proszę wybrać obie daty!");
|
||||
}
|
||||
});
|
||||
|
||||
// Zakresy predefiniowane
|
||||
document.querySelectorAll('.range-btn').forEach(btn => {
|
||||
btn.addEventListener('click', function () {
|
||||
document.querySelectorAll('.range-btn').forEach(b => b.classList.remove('active'));
|
||||
this.classList.add('active');
|
||||
const range = this.getAttribute('data-range');
|
||||
loadExpenses(range);
|
||||
});
|
||||
});
|
||||
});
|
@@ -46,6 +46,19 @@
|
||||
<label class="form-check-label" for="public">Publiczna</label>
|
||||
</div>
|
||||
|
||||
<div class="form-check form-switch mb-3">
|
||||
<input class="form-check-input" type="checkbox" id="temporary" name="temporary" {% if list.is_temporary
|
||||
%}checked{% endif %}>
|
||||
<label class="form-check-label" for="temporary">Tymczasowa</label>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="expires_at" class="form-label">Data wygaśnięcia</label>
|
||||
<input type="datetime-local" class="form-control bg-dark text-white border-secondary rounded" id="expires_at"
|
||||
name="expires_at" value="{{ list.expires_at.strftime('%Y-%m-%dT%H:%M') if list.expires_at else '' }}">
|
||||
</div>
|
||||
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="form-label">Link do udostępnienia</label>
|
||||
<input type="text" class="form-control bg-dark text-white border-secondary rounded" readonly
|
||||
@@ -140,8 +153,6 @@
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
|
||||
|
||||
<td>
|
||||
<form method="post" action="{{ url_for('edit_list', list_id=list.id) }}" class="d-inline">
|
||||
<input type="hidden" name="action" value="delete_item">
|
||||
|
@@ -35,21 +35,22 @@
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if not is_blocked %}
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
{% if request.endpoint and request.endpoint != 'system_auth' %}
|
||||
{% if current_user.is_authenticated and current_user.is_admin %}
|
||||
<a href="{{ url_for('admin_panel') }}" class="btn btn-outline-warning btn-sm">⚙️ Panel admina</a>
|
||||
{% endif %}
|
||||
{% if not is_blocked and request.endpoint and request.endpoint != 'system_auth' %}
|
||||
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||
{% if current_user.is_authenticated %}
|
||||
{% if current_user.is_admin %}
|
||||
<a href="{{ url_for('admin_panel') }}" class="btn btn-outline-light btn-sm">⚙️ Panel admina</a>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('user_expenses') }}" class="btn btn-outline-light btn-sm">📊 Statystyki</a>
|
||||
<a href="{{ url_for('logout') }}" class="btn btn-outline-light btn-sm">🚪 Wyloguj</a>
|
||||
{% else %}
|
||||
<a href="{{ url_for('login') }}" class="btn btn-outline-light btn-sm">🔑 Zaloguj</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
|
84
templates/user_expenses.html
Normal file
84
templates/user_expenses.html
Normal file
@@ -0,0 +1,84 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}📊 Twoje wydatki{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center flex-wrap mb-4">
|
||||
<h2 class="mb-2">📊 Statystyki wydatków</h2>
|
||||
<a href="{{ url_for('main_page') }}" class="btn btn-outline-secondary">← Powrót</a>
|
||||
</div>
|
||||
|
||||
<ul class="nav nav-tabs mb-3" id="expenseTabs" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" id="table-tab" data-bs-toggle="tab" data-bs-target="#tableTab" type="button"
|
||||
role="tab">
|
||||
📄 Tabela
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="chart-tab" data-bs-toggle="tab" data-bs-target="#chartTab" type="button" role="tab">
|
||||
📊 Wykres
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="tab-content" id="expenseTabsContent">
|
||||
<!-- Tabela -->
|
||||
<div class="tab-pane fade show active" id="tableTab" role="tabpanel">
|
||||
<div class="card bg-dark text-white mb-4">
|
||||
<div class="card-body">
|
||||
{% if expense_table %}
|
||||
<div class="row g-4">
|
||||
{% for row in expense_table %}
|
||||
<div class="col-12 col-sm-6 col-lg-4">
|
||||
<div class="card bg-dark text-white border border-secondary h-100 shadow-sm">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title text-truncate" title="{{ row.title }}">{{ row.title }}</h5>
|
||||
<p class="mb-1">💸 <strong>{{ '%.2f'|format(row.amount) }} PLN</strong></p>
|
||||
<p class="mb-0">📅 {{ row.added_at.strftime('%Y-%m-%d') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-info text-center mb-0">Brak wydatków do wyświetlenia.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Wykres -->
|
||||
<div class="tab-pane fade" id="chartTab" role="tabpanel">
|
||||
<div class="card bg-dark text-white mb-4">
|
||||
<div class="card-body">
|
||||
<p id="chartRangeLabel" class="fw-bold mb-3">Widok: miesięczne</p>
|
||||
<canvas id="expensesChart" height="120"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex flex-wrap gap-2 mb-4">
|
||||
<button class="btn btn-outline-light btn-sm range-btn active" data-range="monthly">📅 Miesięczne</button>
|
||||
<button class="btn btn-outline-light btn-sm range-btn" data-range="quarterly">📆 Kwartalne</button>
|
||||
<button class="btn btn-outline-light btn-sm range-btn" data-range="halfyearly">🗓️ Półroczne</button>
|
||||
<button class="btn btn-outline-light btn-sm range-btn" data-range="yearly">📈 Roczne</button>
|
||||
</div>
|
||||
|
||||
<div class="row g-2 mb-4">
|
||||
<div class="col-6 col-md-3">
|
||||
<input type="date" id="startDate" class="form-control bg-dark text-white border-secondary">
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<input type="date" id="endDate" class="form-control bg-dark text-white border-secondary">
|
||||
</div>
|
||||
<div class="col-12 col-md-3">
|
||||
<button class="btn btn-outline-light w-100" id="customRangeBtn">📊 Zakres własny</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="{{ url_for('static_bp.serve_js_lib', filename='chart.js') }}"></script>
|
||||
<script src="{{ url_for('static_bp.serve_js', filename='user_expenses.js') }}"></script>
|
||||
{% endblock %}
|
Reference in New Issue
Block a user