From 34205f0e6570dcf2914482ed942bf75d81212ce4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Thu, 24 Jul 2025 23:30:51 +0200 Subject: [PATCH] commit #1 --- app.py | 77 +++++++++++++--- static/js/functions.js | 155 +++++++++++++++++++------------- static/js/live.js | 96 +++++++++----------- static/js/user_expense_lists.js | 103 +++++++++++++++++++++ templates/list.html | 4 +- templates/user_expenses.html | 91 ++++++++++++++----- 6 files changed, 377 insertions(+), 149 deletions(-) create mode 100644 static/js/user_expense_lists.js diff --git a/app.py b/app.py index d3e57a6..a670afc 100644 --- a/app.py +++ b/app.py @@ -45,7 +45,8 @@ from config import Config from PIL import Image, ExifTags, ImageFilter, ImageOps from werkzeug.utils import secure_filename from werkzeug.middleware.proxy_fix import ProxyFix -from sqlalchemy import func, extract, inspect +from sqlalchemy import func, extract, inspect, or_ +from sqlalchemy.orm import joinedload from collections import defaultdict, deque from functools import wraps @@ -114,7 +115,10 @@ class ShoppingList(db.Model): id = db.Column(db.Integer, primary_key=True) title = db.Column(db.String(150), nullable=False) created_at = db.Column(db.DateTime, default=datetime.utcnow) + owner_id = db.Column(db.Integer, db.ForeignKey("user.id")) + owner = db.relationship("User", backref="lists", foreign_keys=[owner_id]) + is_temporary = db.Column(db.Boolean, default=False) share_token = db.Column(db.String(64), unique=True, nullable=True) # expires_at = db.Column(db.DateTime, nullable=True) @@ -131,6 +135,8 @@ class Item(db.Model): # added_at = db.Column(db.DateTime, default=datetime.utcnow) added_at = db.Column(db.DateTime, default=utcnow) added_by = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True) + added_by_user = db.relationship("User", backref="added_items", lazy=True, foreign_keys=[added_by]) + purchased = db.Column(db.Boolean, default=False) purchased_at = db.Column(db.DateTime, nullable=True) quantity = db.Column(db.Integer, default=1) @@ -958,8 +964,7 @@ def view_list(list_id): @app.route("/user_expenses") @login_required def user_expenses(): - from sqlalchemy.orm import joinedload - + # Lista wydatków użytkownika expenses = ( Expense.query.join(ShoppingList, Expense.list_id == ShoppingList.id) .options(joinedload(Expense.list)) @@ -968,7 +973,7 @@ def user_expenses(): .all() ) - rows = [ + expense_table = [ { "title": e.list.title if e.list else "Nieznana", "amount": e.amount, @@ -977,7 +982,35 @@ def user_expenses(): for e in expenses ] - return render_template("user_expenses.html", expense_table=rows) + lists = ( + ShoppingList.query + .filter( + or_( + ShoppingList.owner_id == current_user.id, + ShoppingList.is_public == True + ) + ) + .order_by(ShoppingList.created_at.desc()) + .all() + ) + + lists_data = [ + { + "id": l.id, + "title": l.title, + "created_at": l.created_at, + "total_expense": sum(e.amount for e in l.expenses), + "owner_username": l.owner.username if l.owner else "?" + } + for l in lists + ] + + + return render_template( + "user_expenses.html", + expense_table=expense_table, + lists_data=lists_data + ) @app.route("/user/expenses_data") @@ -2213,6 +2246,10 @@ def handle_add_item(data): name = data["name"].strip() quantity = data.get("quantity", 1) + list_obj = db.session.get(ShoppingList, list_id) + if not list_obj: + return + try: quantity = int(quantity) if quantity < 1: @@ -2248,12 +2285,15 @@ def handle_add_item(data): if max_position is None: max_position = 0 + user_id = current_user.id if current_user.is_authenticated else None + user_name = current_user.username if current_user.is_authenticated else "Gość" + new_item = Item( list_id=list_id, name=name, quantity=quantity, position=max_position + 1, - added_by=current_user.id if current_user.is_authenticated else None, + added_by=user_id, ) db.session.add(new_item) @@ -2271,9 +2311,9 @@ def handle_add_item(data): "id": new_item.id, "name": new_item.name, "quantity": new_item.quantity, - "added_by": ( - current_user.username if current_user.is_authenticated else "Gość" - ), + "added_by": user_name, + "added_by_id": user_id, + "owner_id": list_obj.owner_id, }, to=str(list_id), include_self=True, @@ -2292,6 +2332,7 @@ def handle_add_item(data): ) + @socketio.on("check_item") def handle_check_item(data): # item = Item.query.get(data["item_id"]) @@ -2345,7 +2386,19 @@ def handle_uncheck_item(data): @socketio.on("request_full_list") def handle_request_full_list(data): list_id = data["list_id"] - items = Item.query.filter_by(list_id=list_id).order_by(Item.position.asc()).all() + + shopping_list = db.session.get(ShoppingList, list_id) + if not shopping_list: + return + + owner_id = shopping_list.owner_id + + items = ( + Item.query.options(joinedload(Item.added_by_user)) + .filter_by(list_id=list_id) + .order_by(Item.position.asc()) + .all() + ) items_data = [] for item in items: @@ -2358,12 +2411,16 @@ def handle_request_full_list(data): "not_purchased": item.not_purchased, "not_purchased_reason": item.not_purchased_reason, "note": item.note or "", + "added_by": item.added_by_user.username if item.added_by_user else None, + "added_by_id": item.added_by_user.id if item.added_by_user else None, + "owner_id": owner_id, } ) emit("full_list", {"items": items_data}, to=request.sid) + @socketio.on("update_note") def handle_update_note(data): item_id = data["item_id"] diff --git a/static/js/functions.js b/static/js/functions.js index 6df9da8..664c5ff 100644 --- a/static/js/functions.js +++ b/static/js/functions.js @@ -272,8 +272,98 @@ function isListDifferent(oldItems, newItems) { return false; } -function updateListSmoothly(newItems) { +function renderItem(item, isShare = window.IS_SHARE, showEditOnly = false) { + const li = document.createElement('li'); + li.id = `item-${item.id}`; + li.dataset.name = item.name.toLowerCase(); + li.className = `list-group-item d-flex justify-content-between align-items-center flex-wrap clickable-item ${item.purchased ? 'bg-success text-white' + : item.not_purchased ? 'bg-warning text-dark' + : 'item-not-checked' + }`; + + let quantityBadge = ''; + if (item.quantity && item.quantity > 1) { + quantityBadge = `x${item.quantity}`; + } + + let checkboxOrIcon = item.not_purchased + ? `🚫` + : ``; + + let noteHTML = item.note + ? `[ ${item.note} ]` : ''; + + let reasonHTML = item.not_purchased_reason + ? `[ Powód: ${item.not_purchased_reason} ]` : ''; + + let left = ` +
+ ${window.isSorting ? `` : ''} + ${checkboxOrIcon} + ${item.name} ${quantityBadge} + ${noteHTML} + ${reasonHTML} +
`; + + let rightButtons = ''; + + // ✏️ i 🗑️ — tylko jeśli nie jesteśmy w trybie /share lub jesteśmy w 15s (tymczasowo) + if (!isShare || showEditOnly) { + rightButtons += ` + + `; + } + + // ✅ Jeśli element jest oznaczony jako niekupiony — pokaż "Przywróć" + if (item.not_purchased) { + rightButtons += ` + `; + } + + // ⚠️ tylko jeśli NIE jest oznaczony jako niekupiony i nie jesteśmy w 15s + if (!item.not_purchased && !showEditOnly) { + rightButtons += ` + `; + } + + // 📝 tylko jeśli jesteśmy w /share i nie jesteśmy w 15s + if (isShare && !showEditOnly) { + rightButtons += ` + `; + } + + + li.innerHTML = `${left}
${rightButtons}
`; + + if (item.added_by && item.owner_id && item.added_by_id && item.added_by_id !== item.owner_id) { + const infoEl = document.createElement('small'); + infoEl.className = 'text-info ms-4'; + infoEl.innerHTML = `[Dodane przez: ${item.added_by}]`; + li.querySelector('.d-flex.align-items-center')?.appendChild(infoEl); + } + + return li; +} + + + +function updateListSmoothly(newItems) { const itemsContainer = document.getElementById('items'); const existingItemsMap = new Map(); @@ -285,68 +375,7 @@ function updateListSmoothly(newItems) { const fragment = document.createDocumentFragment(); newItems.forEach(item => { - let li = existingItemsMap.get(item.id); - let quantityBadge = ''; - if (item.quantity && item.quantity > 1) { - quantityBadge = `x${item.quantity}`; - } - - if (!li) { - li = document.createElement('li'); - li.id = `item-${item.id}`; - } - - // Klasy tła - li.className = `list-group-item d-flex justify-content-between align-items-center flex-wrap clickable-item ${item.purchased ? 'bg-success text-white' : - item.not_purchased ? 'bg-warning text-dark' : 'item-not-checked' - }`; - - // Wewnętrzny HTML - li.innerHTML = ` -
- ${isSorting ? `` : ''} - ${!item.not_purchased ? ` - - ` : ` - 🚫 - `} - ${item.name} ${quantityBadge} - - ${item.note ? `[ ${item.note} ]` : ''} - ${item.not_purchased_reason ? `[ Powód: ${item.not_purchased_reason} ]` : ''} -
-
- ${item.not_purchased ? ` - - ` : ` - - ${window.IS_SHARE ? ` - - ` : ''} - `} - ${!window.IS_SHARE ? ` - - - ` : ''} -
- `; - + const li = renderItem(item); fragment.appendChild(li); }); diff --git a/static/js/live.js b/static/js/live.js index e96ebdb..4f13c51 100644 --- a/static/js/live.js +++ b/static/js/live.js @@ -127,69 +127,59 @@ function setupList(listId, username) { showToast(`Dodano wydatek: ${data.amount.toFixed(2)} PLN`, 'info'); }); + socket.on('item_added', data => { showToast(`${data.added_by} dodał: ${data.name}`, 'info'); - const li = document.createElement('li'); - li.className = 'list-group-item d-flex justify-content-between align-items-center flex-wrap item-not-checked'; - li.id = `item-${data.id}`; - let quantityBadge = ''; - if (data.quantity && data.quantity > 1) { - quantityBadge = `x${data.quantity}`; - } - - const countdownId = `countdown-${data.id}`; - const countdownBtn = ` - - `; - - li.innerHTML = ` -
- - - ${data.name} ${quantityBadge} - -
-
- ${countdownBtn} - - -
- `; + const item = { + ...data, + purchased: false, + not_purchased: false, + not_purchased_reason: '', + note: '' + }; + const li = renderItem(item, false, true); // ← tryb 15s document.getElementById('items').appendChild(li); toggleEmptyPlaceholder(); + updateProgressBar(); - // ⏳ Licznik odliczania - let seconds = 15; - const countdownEl = document.getElementById(countdownId); - const intervalId = setInterval(() => { - seconds--; - if (countdownEl) { - countdownEl.textContent = `${seconds}s`; - } - if (seconds <= 0) { - clearInterval(intervalId); - if (countdownEl) countdownEl.remove(); - } - }, 1000); + if (window.IS_SHARE) { + const countdownId = `countdown-${data.id}`; + const countdownBtn = document.createElement('button'); + countdownBtn.type = 'button'; + countdownBtn.className = 'btn btn-outline-warning'; + countdownBtn.id = countdownId; + countdownBtn.disabled = true; + countdownBtn.textContent = '15s'; - // 🔁 Request listy po 15s - setTimeout(() => { - if (window.LIST_ID) { - socket.emit('request_full_list', { list_id: window.LIST_ID }); - } - }, 15000); + li.querySelector('.btn-group')?.prepend(countdownBtn); + + let seconds = 15; + const intervalId = setInterval(() => { + const el = document.getElementById(countdownId); + if (el) { + seconds--; + el.textContent = `${seconds}s`; + if (seconds <= 0) { + el.remove(); + clearInterval(intervalId); + } + } else { + clearInterval(intervalId); + } + }, 1000); + + setTimeout(() => { + const existing = document.getElementById(`item-${data.id}`); + if (existing) { + const updated = renderItem(item, true); + existing.replaceWith(updated); + } + }, 15000); + } }); - - socket.on('item_deleted', data => { const li = document.getElementById(`item-${data.item_id}`); if (li) { diff --git a/static/js/user_expense_lists.js b/static/js/user_expense_lists.js new file mode 100644 index 0000000..fa6cdd9 --- /dev/null +++ b/static/js/user_expense_lists.js @@ -0,0 +1,103 @@ +document.addEventListener('DOMContentLoaded', () => { + const checkboxes = document.querySelectorAll('.list-checkbox'); + const totalEl = document.getElementById('listsTotal'); + const filterButtons = document.querySelectorAll('.filter-btn'); + const rows = document.querySelectorAll('#listsTableBody tr'); + + const onlyWith = document.getElementById('onlyWithExpenses'); + const customStart = document.getElementById('customStart'); + const customEnd = document.getElementById('customEnd'); + + function updateTotal() { + let total = 0; + checkboxes.forEach(cb => { + const row = cb.closest('tr'); + if (cb.checked && row.style.display !== 'none') { + total += parseFloat(cb.dataset.amount); + } + }); + + totalEl.textContent = total.toFixed(2) + ' PLN'; + totalEl.parentElement.classList.add('animate__animated', 'animate__fadeIn'); + setTimeout(() => { + totalEl.parentElement.classList.remove('animate__animated', 'animate__fadeIn'); + }, 400); + } + + checkboxes.forEach(cb => cb.addEventListener('change', updateTotal)); + + filterButtons.forEach(btn => { + btn.addEventListener('click', () => { + filterButtons.forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + const range = btn.dataset.range; + + const now = new Date(); + const todayStr = now.toISOString().slice(0, 10); + const year = now.getFullYear(); + const month = now.toISOString().slice(0, 7); + const week = `${year}-${String(getISOWeek(now)).padStart(2, '0')}`; + + rows.forEach(row => { + const rDate = row.dataset.date; + const rMonth = row.dataset.month; + const rWeek = row.dataset.week; + const rYear = row.dataset.year; + + let show = true; + if (range === 'day') show = rDate === todayStr; + if (range === 'month') show = rMonth === month; + if (range === 'week') show = rWeek === week; + if (range === 'year') show = rYear === String(year); + + row.style.display = show ? '' : 'none'; + }); + + applyExpenseFilter(); + updateTotal(); + }); + }); + + // ISO week helper + function getISOWeek(date) { + const target = new Date(date.valueOf()); + const dayNr = (date.getDay() + 6) % 7; + target.setDate(target.getDate() - dayNr + 3); + const firstThursday = new Date(target.getFullYear(), 0, 4); + const dayDiff = (target - firstThursday) / 86400000; + return 1 + Math.floor(dayDiff / 7); + } + + // Zakres dat: kliknij „Zastosuj zakres” + document.getElementById('applyCustomRange').addEventListener('click', () => { + const start = customStart.value; + const end = customEnd.value; + + filterButtons.forEach(b => b.classList.remove('active')); + + rows.forEach(row => { + const date = row.dataset.date; + const show = (!start || date >= start) && (!end || date <= end); + row.style.display = show ? '' : 'none'; + }); + + applyExpenseFilter(); + updateTotal(); + }); + + // Filtrowanie tylko list z wydatkami + if (onlyWith) { + onlyWith.addEventListener('change', () => { + applyExpenseFilter(); + updateTotal(); + }); + } + + function applyExpenseFilter() { + if (!onlyWith || !onlyWith.checked) return; + rows.forEach(row => { + const amt = parseFloat(row.querySelector('.list-checkbox').dataset.amount || 0); + if (amt <= 0) row.style.display = 'none'; + }); + } +}); diff --git a/templates/list.html b/templates/list.html index 7a69337..c1830d1 100644 --- a/templates/list.html +++ b/templates/list.html @@ -131,11 +131,11 @@ {% endif %} {% if not is_share %} - - diff --git a/templates/user_expenses.html b/templates/user_expenses.html index d351519..2623592 100644 --- a/templates/user_expenses.html +++ b/templates/user_expenses.html @@ -10,12 +10,14 @@
- -
+ + +
- {% if expense_table %} -
- {% for row in expense_table %} -
-
-
-
{{ row.title }}
-

💸 {{ '%.2f'|format(row.amount) }} PLN

-

📅 {{ row.added_at.strftime('%Y-%m-%d') }}

-
-
-
- {% endfor %} + +
+ + + + +
- {% else %} -
Brak wydatków do wyświetlenia.
- {% endif %} + +
+
+ +
+
+ +
+
+ +
+
+
+ + +
+
+ + + + + + + + + + + {% for list in lists_data %} + + + + + + + {% endfor %} + +
Nazwa listyDataWydatki (PLN)
+ + + {{ list.title }} +
👤 {{ list.owner_username or '?' }} + +
{{ list.created_at.strftime('%Y-%m-%d') }}{{ '%.2f'|format(list.total_expense) }}
+
+ +
+
💰 Suma zaznaczonych: 0.00 PLN
- + +
@@ -78,6 +125,7 @@
+
@@ -86,4 +134,5 @@ {% block scripts %} + {% endblock %} \ No newline at end of file