duzo zmian ux

This commit is contained in:
Mateusz Gruszczyński committed 2025-07-07 12:59:18 +02:00
1 parent 8854d5b558
commit 86bf3e1a86
10 files changed
+521 -62

No files matched your search

+175 -11
View File
@@ -3,7 +3,7 @@ import secrets
import time
import mimetypes
from datetime import datetime, timedelta
from flask import Flask, render_template, redirect, url_for, request, flash, Blueprint, send_from_directory, request, abort, session
from flask import Flask, render_template, redirect, url_for, request, flash, Blueprint, send_from_directory, request, abort, session, jsonify, make_response
from markupsafe import Markup
from flask_sqlalchemy import SQLAlchemy
from flask_login import LoginManager, UserMixin, login_user, login_required, logout_user, current_user
@@ -270,11 +270,15 @@ def index_guest():
now = datetime.utcnow()
if current_user.is_authenticated:
# Twoje listy
# Twoje listy aktywne
user_lists = ShoppingList.query.filter_by(owner_id=current_user.id, is_archived=False).filter(
(ShoppingList.expires_at == None) | (ShoppingList.expires_at > now)
).order_by(ShoppingList.created_at.desc()).all()
# Zarchiwizowane listy
archived_lists = ShoppingList.query.filter_by(owner_id=current_user.id, is_archived=True).order_by(ShoppingList.created_at.desc()).all()
# Publiczne listy innych użytkowników
public_lists = ShoppingList.query.filter(
ShoppingList.is_public == True,
ShoppingList.owner_id != current_user.id,
@@ -283,20 +287,22 @@ def index_guest():
).order_by(ShoppingList.created_at.desc()).all()
else:
user_lists = []
archived_lists = []
public_lists = ShoppingList.query.filter(
ShoppingList.is_public == True,
((ShoppingList.expires_at == None) | (ShoppingList.expires_at > now)),
ShoppingList.is_archived == False
).order_by(ShoppingList.created_at.desc()).all()
for l in user_lists + public_lists:
# Dodajemy dane o przedmiotach i wydatkach
for l in user_lists + public_lists + archived_lists:
items = Item.query.filter_by(list_id=l.id).all()
l.total_count = len(items)
l.purchased_count = len([i for i in items if i.purchased])
expenses = Expense.query.filter_by(list_id=l.id).all()
l.total_expense = sum(e.amount for e in expenses)
return render_template("main.html", user_lists=user_lists, public_lists=public_lists)
return render_template("main.html", user_lists=user_lists, public_lists=public_lists, archived_lists=archived_lists)
@app.route('/system-auth', methods=['GET', 'POST'])
def system_auth():
@@ -322,16 +328,25 @@ def system_auth():
flash(f'Nieprawidłowe hasło do systemu. Pozostało prób: {remaining}', 'warning')
return render_template('system_auth.html')
@app.route('/archive_my_list/<int:list_id>')
@app.route('/toggle_archive_list/<int:list_id>')
@login_required
def archive_my_list(list_id):
def toggle_archive_list(list_id):
l = ShoppingList.query.get_or_404(list_id)
if l.owner_id != current_user.id:
flash('Nie masz uprawnień do tej listy', 'danger')
return redirect(url_for('index_guest'))
l.is_archived = True
# Pobieramy parametr archive z query string
archive = request.args.get('archive', 'true').lower() == 'true'
if archive:
l.is_archived = True
flash(f'Lista „{l.title}” została zarchiwizowana.', 'success')
else:
l.is_archived = False
flash(f'Lista „{l.title}” została przywrócona.', 'success')
db.session.commit()
flash('Lista została zarchiwizowana', 'success')
return redirect(url_for('index_guest'))
@app.route('/edit_my_list/<int:list_id>', methods=['GET', 'POST'])
@@ -378,7 +393,6 @@ def toggle_visibility(list_id):
return redirect(url_for('index_guest'))
@app.route('/login', methods=['GET', 'POST'])
def login():
if request.method == 'POST':
@@ -485,7 +499,7 @@ def guest_list(list_id):
@login_required
def copy_list(list_id):
original = ShoppingList.query.get_or_404(list_id)
token = secrets.token_hex(16)
token = secrets.token_hex(8)
new_list = ShoppingList(title=original.title + ' (Kopia)', owner_id=current_user.id, share_token=token)
db.session.add(new_list)
db.session.commit()
@@ -547,7 +561,8 @@ def uploaded_file(filename):
def admin_panel():
if not current_user.is_admin:
return redirect(url_for('index_guest'))
now = datetime.utcnow()
user_count = User.query.count()
list_count = ShoppingList.query.count()
item_count = Item.query.count()
@@ -614,6 +629,7 @@ def admin_panel():
total_expense_sum=total_expense_sum,
year_expense_sum=year_expense_sum,
month_expense_sum=month_expense_sum,
now=now
)
@app.route('/admin/delete_list/<int:list_id>')
@@ -805,7 +821,155 @@ def edit_list(list_id):
return render_template('admin/edit_list.html', list=l, total_expense=total_expense, users=users)
@app.route('/admin/products')
@login_required
def list_products():
if not current_user.is_admin:
return redirect(url_for('index_guest'))
items = Item.query.order_by(Item.id.desc()).all()
users = User.query.all()
users_dict = {user.id: user.username for user in users}
# Wszystkie sugestie do słownika
suggestions = SuggestedProduct.query.all()
suggestions_dict = {s.name.lower(): s for s in suggestions}
return render_template(
'admin/list_products.html',
items=items,
users_dict=users_dict,
suggestions_dict=suggestions_dict
)
@app.route('/admin/sync_suggestion/<item_name>')
@login_required
def sync_suggestion(item_name):
if not current_user.is_admin:
return redirect(url_for('index_guest'))
existing = SuggestedProduct.query.filter(func.lower(SuggestedProduct.name) == item_name.lower()).first()
if not existing:
new_suggestion = SuggestedProduct(name=item_name)
db.session.add(new_suggestion)
db.session.commit()
flash(f'Utworzono sugestię dla produktu: {item_name}', 'success')
else:
flash(f'Sugestia dla produktu "{item_name}" już istnieje.', 'info')
return redirect(url_for('list_products'))
@app.route('/admin/delete_suggestion/<int:suggestion_id>')
@login_required
def delete_suggestion(suggestion_id):
if not current_user.is_admin:
return redirect(url_for('index_guest'))
suggestion = SuggestedProduct.query.get_or_404(suggestion_id)
db.session.delete(suggestion)
db.session.commit()
flash('Sugestia została usunięta', 'success')
return redirect(url_for('list_products'))
@app.route('/admin/expenses_data')
@login_required
def admin_expenses_data():
if not current_user.is_admin:
return jsonify({'error': 'Unauthorized'}), 403
range_type = request.args.get('range', 'monthly')
start_date_str = request.args.get('start_date')
end_date_str = request.args.get('end_date')
now = datetime.utcnow()
labels = []
expenses = []
if start_date_str and end_date_str:
start_date = datetime.strptime(start_date_str, '%Y-%m-%d')
end_date = datetime.strptime(end_date_str, '%Y-%m-%d')
expenses_query = (
db.session.query(
extract('year', Expense.added_at).label('year'),
extract('month', Expense.added_at).label('month'),
func.sum(Expense.amount).label('total')
)
.filter(Expense.added_at >= start_date, Expense.added_at <= end_date)
.group_by('year', 'month')
.order_by('year', 'month')
.all()
)
for row in expenses_query:
label = f"{int(row.month):02d}/{int(row.year)}"
labels.append(label)
expenses.append(round(row.total, 2))
response = make_response(jsonify({'labels': labels, 'expenses': expenses}))
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
return response
if range_type == 'monthly':
for i in range(11, -1, -1):
year = (now - timedelta(days=i*30)).year
month = (now - timedelta(days=i*30)).month
label = f"{month:02d}/{year}"
labels.append(label)
month_sum = (
db.session.query(func.sum(Expense.amount))
.filter(extract('year', Expense.added_at) == year)
.filter(extract('month', Expense.added_at) == month)
.scalar() or 0
)
expenses.append(round(month_sum, 2))
elif range_type == 'quarterly':
for i in range(3, -1, -1):
quarter_start = now - timedelta(days=i*90)
year = quarter_start.year
quarter = (quarter_start.month - 1) // 3 + 1
label = f"Q{quarter}/{year}"
quarter_sum = (
db.session.query(func.sum(Expense.amount))
.filter(extract('year', Expense.added_at) == year)
.filter((extract('month', Expense.added_at) - 1)//3 + 1 == quarter)
.scalar() or 0
)
labels.append(label)
expenses.append(round(quarter_sum, 2))
elif range_type == 'halfyearly':
for i in range(1, -1, -1):
half_start = now - timedelta(days=i*180)
year = half_start.year
half = 1 if half_start.month <= 6 else 2
label = f"H{half}/{year}"
half_sum = (
db.session.query(func.sum(Expense.amount))
.filter(extract('year', Expense.added_at) == year)
.filter(
(extract('month', Expense.added_at) <= 6) if half == 1 else (extract('month', Expense.added_at) > 6)
)
.scalar() or 0
)
labels.append(label)
expenses.append(round(half_sum, 2))
elif range_type == 'yearly':
for i in range(4, -1, -1):
year = now.year - i
label = str(year)
year_sum = (
db.session.query(func.sum(Expense.amount))
.filter(extract('year', Expense.added_at) == year)
.scalar() or 0
)
labels.append(label)
expenses.append(round(year_sum, 2))
response = make_response(jsonify({'labels': labels, 'expenses': expenses}))
response.headers["Cache-Control"] = "no-store, no-cache"
return response
# chyba do usuniecia przeniesione na eventy socket.io
@app.route('/update-note/<int:item_id>', methods=['POST'])
+10
View File
@@ -123,4 +123,14 @@ input[type="checkbox"]:disabled::before {
}
input[type="checkbox"]:disabled {
cursor: not-allowed;
}
#tempToggle {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
input.form-control {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
+93
View File
@@ -0,0 +1,93 @@
document.addEventListener("DOMContentLoaded", function() {
let expensesChart = null;
const rangeLabel = document.getElementById("chartRangeLabel");
function loadExpenses(range = "monthly", startDate = null, endDate = null) {
let url = '/admin/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);
});
}
document.getElementById('loadExpensesBtn').addEventListener('click', function() {
loadExpenses();
});
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);
});
});
document.getElementById('customRangeBtn').addEventListener('click', function() {
const startDate = document.getElementById('startDate').value;
const endDate = document.getElementById('endDate').value;
if (startDate && endDate) {
document.querySelectorAll('.range-btn').forEach(b => b.classList.remove('active'));
loadExpenses('custom', startDate, endDate);
} else {
alert("Proszę wybrać obie daty!");
}
});
});
document.addEventListener("DOMContentLoaded", function() {
const startDateInput = document.getElementById("startDate");
const endDateInput = document.getElementById("endDate");
const today = new Date();
const threeDaysAgo = new Date(today);
threeDaysAgo.setDate(today.getDate() - 7);
const formatDate = (d) => d.toISOString().split('T')[0];
startDateInput.value = formatDate(threeDaysAgo);
endDateInput.value = formatDate(today);
});
+32
View File
@@ -0,0 +1,32 @@
document.addEventListener("DOMContentLoaded", function() {
const toggleBtn = document.getElementById("tempToggle");
const hiddenInput = document.getElementById("temporaryHidden");
// Inicjalizacja tooltipa
const tooltip = new bootstrap.Tooltip(toggleBtn);
// Funkcja aktualizująca wygląd
function updateToggle(isActive) {
if (isActive) {
toggleBtn.classList.remove("btn-outline-secondary");
toggleBtn.classList.add("btn-success");
toggleBtn.textContent = "Tymczasowa ✔️";
} else {
toggleBtn.classList.remove("btn-success");
toggleBtn.classList.add("btn-outline-secondary");
toggleBtn.textContent = "Tymczasowa";
}
}
// Inicjalizacja stanu
let active = toggleBtn.getAttribute("data-active") === "1";
updateToggle(active);
// Obsługa kliknięcia
toggleBtn.addEventListener("click", function() {
active = !active;
toggleBtn.setAttribute("data-active", active ? "1" : "0");
hiddenInput.value = active ? "1" : "0";
updateToggle(active);
});
});
+14
View File
@@ -0,0 +1,14 @@
/*!
* Chart.js v4.5.0
* https://www.chartjs.org
* (c) 2025 Chart.js Contributors
* Released under the MIT License
*/
!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).Chart=e()}(this,(function(){"use strict";var t=Object.freeze({__proto__:null,get Colors(){return Jo},get Decimation(){return ta},get Filler(){return ba},get Legend(){return Ma},get SubTitle(){return Pa},get Title(){return ka},get Tooltip(){return Na}});function e(){}const i=(()=>{let t=0;return()=>t++})();function s(t){return null==t}function n(t){if(Array.isArray&&Array.isArray(t))return!0;const e=Object.prototype.toString.call(t);return"[object"===e.slice(0,7)&&"Array]"===e.slice(-6)}function o(t){return null!==t&&"[object Object]"===Object.prototype.toString.call(t)}function a(t){return("number"==typeof t||t instanceof Number)&&isFinite(+t)}function r(t,e){return a(t)?t:e}function l(t,e){return void 0===t?e:t}const h=(t,e)=>"string"==typeof t&&t.endsWith("%")?parseFloat(t)/100:+t/e,c=(t,e)=>"string"==typeof t&&t.endsWith("%")?parseFloat(t)/100*e:+t;function d(t,e,i){if(t&&"function"==typeof t.call)return t.apply(i,e)}function u(t,e,i,s){let a,r,l;if(n(t))if(r=t.length,s)for(a=r-1;a>=0;a--)e.call(i,t[a],a);else for(a=0;a<r;a++)e.call(i,t[a],a);else if(o(t))for(l=Object.keys(t),r=l.length,a=0;a<r;a++)e.call(i,t[l[a]],l[a])}function f(t,e){let i,s,n,o;if(!t||!e||t.length!==e.length)return!1;for(i=0,s=t.length;i<s;++i)if(n=t[i],o=e[i],n.datasetIndex!==o.datasetIndex||n.index!==o.index)return!1;return!0}function g(t){if(n(t))return t.map(g);if(o(t)){const e=Object.create(null),i=Object.keys(t),s=i.length;let n=0;for(;n<s;++n)e[i[n]]=g(t[i[n]]);return e}return t}function p(t){return-1===["__proto__","prototype","constructor"].indexOf(t)}function m(t,e,i,s){if(!p(t))return;const n=e[t],a=i[t];o(n)&&o(a)?x(n,a,s):e[t]=g(a)}function x(t,e,i){const s=n(e)?e:[e],a=s.length;if(!o(t))return t;const r=(i=i||{}).merger||m;let l;for(let e=0;e<a;++e){if(l=s[e],!o(l))continue;const n=Object.keys(l);for(let e=0,s=n.length;e<s;++e)r(n[e],t,l,i)}return t}function b(t,e){return x(t,e,{merger:_})}function _(t,e,i){if(!p(t))return;const s=e[t],n=i[t];o(s)&&o(n)?b(s,n):Object.prototype.hasOwnProperty.call(e,t)||(e[t]=g(n))}const y={"":t=>t,x:t=>t.x,y:t=>t.y};function v(t){const e=t.split("."),i=[];let s="";for(const t of e)s+=t,s.endsWith("\\")?s=s.slice(0,-1)+".":(i.push(s),s="");return i}function M(t,e){const i=y[e]||(y[e]=function(t){const e=v(t);return t=>{for(const i of e){if(""===i)break;t=t&&t[i]}return t}}(e));return i(t)}function w(t){return t.charAt(0).toUpperCase()+t.slice(1)}const k=t=>void 0!==t,S=t=>"function"==typeof t,P=(t,e)=>{if(t.size!==e.size)return!1;for(const i of t)if(!e.has(i))return!1;return!0};function D(t){return"mouseup"===t.type||"click"===t.type||"contextmenu"===t.type}const C=Math.PI,O=2*C,A=O+C,T=Number.POSITIVE_INFINITY,L=C/180,E=C/2,R=C/4,I=2*C/3,z=Math.log10,F=Math.sign;function V(t,e,i){return Math.abs(t-e)<i}function B(t){const e=Math.round(t);t=V(t,e,t/1e3)?e:t;const i=Math.pow(10,Math.floor(z(t))),s=t/i;return(s<=1?1:s<=2?2:s<=5?5:10)*i}function W(t){const e=[],i=Math.sqrt(t);let s;for(s=1;s<i;s++)t%s==0&&(e.push(s),e.push(t/s));return i===(0|i)&&e.push(i),e.sort(((t,e)=>t-e)).pop(),e}function N(t){return!function(t){return"symbol"==typeof t||"object"==typeof t&&null!==t&&!(Symbol.toPrimitive in t||"toString"in t||"valueOf"in t)}(t)&&!isNaN(parseFloat(t))&&isFinite(t)}function H(t,e){const i=Math.round(t);return i-e<=t&&i+e>=t}function j(t,e,i){let s,n,o;for(s=0,n=t.length;s<n;s++)o=t[s][i],isNaN(o)||(e.min=Math.min(e.min,o),e.max=Math.max(e.max,o))}function $(t){return t*(C/180)}function Y(t){return t*(180/C)}function U(t){if(!a(t))return;let e=1,i=0;for(;Math.round(t*e)/e!==t;)e*=10,i++;return i}function X(t,e){const i=e.x-t.x,s=e.y-t.y,n=Math.sqrt(i*i+s*s);let o=Math.atan2(s,i);return o<-.5*C&&(o+=O),{angle:o,distance:n}}function q(t,e){return Math.sqrt(Math.pow(e.x-t.x,2)+Math.pow(e.y-t.y,2))}function K(t,e){return(t-e+A)%O-C}function G(t){return(t%O+O)%O}function J(t,e,i,s){const n=G(t),o=G(e),a=G(i),r=G(o-n),l=G(a-n),h=G(n-o),c=G(n-a);return n===o||n===a||s&&o===a||r>l&&h<c}function Z(t,e,i){return Math.max(e,Math.min(i,t))}function Q(t){return Z(t,-32768,32767)}function tt(t,e,i,s=1e-6){return t>=Math.min(e,i)-s&&t<=Math.max(e,i)+s}function et(t,e,i){i=i||(i=>t[i]<e);let s,n=t.length-1,o=0;for(;n-o>1;)s=o+n>>1,i(s)?o=s:n=s;return{lo:o,hi:n}}const it=(t,e,i,s)=>et(t,i,s?s=>{const n=t[s][e];return n<i||n===i&&t[s+1][e]===i}:s=>t[s][e]<i),st=(t,e,i)=>et(t,i,(s=>t[s][e]>=i));function nt(t,e,i){let s=0,n=t.length;for(;s<n&&t[s]<e;)s++;for(;n>s&&t[n-1]>i;)n--;return s>0||n<t.length?t.slice(s,n):t}const ot=["push","pop","shift","splice","unshift"];function at(t,e){t._chartjs?t._chartjs.listeners.push(e):(Object.defineProperty(t,"_chartjs",{configurable:!0,enumerable:!1,value:{listeners:[e]}}),ot.forEach((e=>{const i="_onData"+w(e),s=t[e];Object.defineProperty(t,e,{configurable:!0,enumerable:!1,value(...eLine truncated
/*!
* @kurkle/color v0.3.2
* https://github.com/kurkle/color#readme
* (c) 2023 Jukka Kurkela
* Released under the MIT License
*/function _t(t){return t+.5|0}const yt=(t,e,i)=>Math.max(Math.min(t,i),e);function vt(t){return yt(_t(2.55*t),0,255)}function Mt(t){return yt(_t(255*t),0,255)}function wt(t){return yt(_t(t/2.55)/100,0,1)}function kt(t){return yt(_t(100*t),0,100)}const St={0:0,1:1,2:2,3:3,4:4,5:5,6:6,7:7,8:8,9:9,A:10,B:11,C:12,D:13,E:14,F:15,a:10,b:11,c:12,d:13,e:14,f:15},Pt=[..."0123456789ABCDEF"],Dt=t=>Pt[15&t],Ct=t=>Pt[(240&t)>>4]+Pt[15&t],Ot=t=>(240&t)>>4==(15&t);function At(t){var e=(t=>Ot(t.r)&&Ot(t.g)&&Ot(t.b)&&Ot(t.a))(t)?Dt:Ct;return t?"#"+e(t.r)+e(t.g)+e(t.b)+((t,e)=>t<255?e(t):"")(t.a,e):void 0}const Tt=/^(hsla?|hwb|hsv)\(\s*([-+.e\d]+)(?:deg)?[\s,]+([-+.e\d]+)%[\s,]+([-+.e\d]+)%(?:[\s,]+([-+.e\d]+)(%)?)?\s*\)$/;function Lt(t,e,i){const s=e*Math.min(i,1-i),n=(e,n=(e+t/30)%12)=>i-s*Math.max(Math.min(n-3,9-n,1),-1);return[n(0),n(8),n(4)]}function Et(t,e,i){const s=(s,n=(s+t/60)%6)=>i-i*e*Math.max(Math.min(n,4-n,1),0);return[s(5),s(3),s(1)]}function Rt(t,e,i){const s=Lt(t,1,.5);let n;for(e+i>1&&(n=1/(e+i),e*=n,i*=n),n=0;n<3;n++)s[n]*=1-e-i,s[n]+=e;return s}function It(t){const e=t.r/255,i=t.g/255,s=t.b/255,n=Math.max(e,i,s),o=Math.min(e,i,s),a=(n+o)/2;let r,l,h;return n!==o&&(h=n-o,l=a>.5?h/(2-n-o):h/(n+o),r=function(t,e,i,s,n){return t===n?(e-i)/s+(e<i?6:0):e===n?(i-t)/s+2:(t-e)/s+4}(e,i,s,h,n),r=60*r+.5),[0|r,l||0,a]}function zt(t,e,i,s){return(Array.isArray(e)?t(e[0],e[1],e[2]):t(e,i,s)).map(Mt)}function Ft(t,e,i){return zt(Lt,t,e,i)}function Vt(t){return(t%360+360)%360}function Bt(t){const e=Tt.exec(t);let i,s=255;if(!e)return;e[5]!==i&&(s=e[6]?vt(+e[5]):Mt(+e[5]));const n=Vt(+e[2]),o=+e[3]/100,a=+e[4]/100;return i="hwb"===e[1]?function(t,e,i){return zt(Rt,t,e,i)}(n,o,a):"hsv"===e[1]?function(t,e,i){return zt(Et,t,e,i)}(n,o,a):Ft(n,o,a),{r:i[0],g:i[1],b:i[2],a:s}}const Wt={x:"dark",Z:"light",Y:"re",X:"blu",W:"gr",V:"medium",U:"slate",A:"ee",T:"ol",S:"or",B:"ra",C:"lateg",D:"ights",R:"in",Q:"turquois",E:"hi",P:"ro",O:"al",N:"le",M:"de",L:"yello",F:"en",K:"ch",G:"arks",H:"ea",I:"ightg",J:"wh"},Nt={OiceXe:"f0f8ff",antiquewEte:"faebd7",aqua:"ffff",aquamarRe:"7fffd4",azuY:"f0ffff",beige:"f5f5dc",bisque:"ffe4c4",black:"0",blanKedOmond:"ffebcd",Xe:"ff",XeviTet:"8a2be2",bPwn:"a52a2a",burlywood:"deb887",caMtXe:"5f9ea0",KartYuse:"7fff00",KocTate:"d2691e",cSO:"ff7f50",cSnflowerXe:"6495ed",cSnsilk:"fff8dc",crimson:"dc143c",cyan:"ffff",xXe:"8b",xcyan:"8b8b",xgTMnPd:"b8860b",xWay:"a9a9a9",xgYF:"6400",xgYy:"a9a9a9",xkhaki:"bdb76b",xmagFta:"8b008b",xTivegYF:"556b2f",xSange:"ff8c00",xScEd:"9932cc",xYd:"8b0000",xsOmon:"e9967a",xsHgYF:"8fbc8f",xUXe:"483d8b",xUWay:"2f4f4f",xUgYy:"2f4f4f",xQe:"ced1",xviTet:"9400d3",dAppRk:"ff1493",dApskyXe:"bfff",dimWay:"696969",dimgYy:"696969",dodgerXe:"1e90ff",fiYbrick:"b22222",flSOwEte:"fffaf0",foYstWAn:"228b22",fuKsia:"ff00ff",gaRsbSo:"dcdcdc",ghostwEte:"f8f8ff",gTd:"ffd700",gTMnPd:"daa520",Way:"808080",gYF:"8000",gYFLw:"adff2f",gYy:"808080",honeyMw:"f0fff0",hotpRk:"ff69b4",RdianYd:"cd5c5c",Rdigo:"4b0082",ivSy:"fffff0",khaki:"f0e68c",lavFMr:"e6e6fa",lavFMrXsh:"fff0f5",lawngYF:"7cfc00",NmoncEffon:"fffacd",ZXe:"add8e6",ZcSO:"f08080",Zcyan:"e0ffff",ZgTMnPdLw:"fafad2",ZWay:"d3d3d3",ZgYF:"90ee90",ZgYy:"d3d3d3",ZpRk:"ffb6c1",ZsOmon:"ffa07a",ZsHgYF:"20b2aa",ZskyXe:"87cefa",ZUWay:"778899",ZUgYy:"778899",ZstAlXe:"b0c4de",ZLw:"ffffe0",lime:"ff00",limegYF:"32cd32",lRF:"faf0e6",magFta:"ff00ff",maPon:"800000",VaquamarRe:"66cdaa",VXe:"cd",VScEd:"ba55d3",VpurpN:"9370db",VsHgYF:"3cb371",VUXe:"7b68ee",VsprRggYF:"fa9a",VQe:"48d1cc",VviTetYd:"c71585",midnightXe:"191970",mRtcYam:"f5fffa",mistyPse:"ffe4e1",moccasR:"ffe4b5",navajowEte:"ffdead",navy:"80",Tdlace:"fdf5e6",Tive:"808000",TivedBb:"6b8e23",Sange:"ffa500",SangeYd:"ff4500",ScEd:"da70d6",pOegTMnPd:"eee8aa",pOegYF:"98fb98",pOeQe:"afeeee",pOeviTetYd:"db7093",papayawEp:"ffefd5",pHKpuff:"ffdab9",peru:"cd853f",pRk:"ffc0cb",plum:"dda0dd",powMrXe:"b0e0e6",purpN:"800080",YbeccapurpN:"663399",Yd:"ff0000",Psybrown:"bc8f8f",PyOXe:"4169e1",saddNbPwn:"8b4513",sOmon:"fa8072",sandybPwn:"f4a460",sHgYF:"2e8b57",sHshell:"fff5ee",siFna:"a0522d",silver:"c0c0c0",skyXe:"87ceeb",UXe:"6a5acd",UWay:"708090",UgYy:"708090",snow:"fffafa",sprRggYF:"ff7f",stAlXe:"4682b4",tan:"d2b48c",teO:"8080",tEstN:"d8bfd8",tomato:"ff6347",Qe:"40e0d0",viTet:"ee82ee",JHt:"f5deb3",wEte:"ffffff",wEtesmoke:"f5f5f5",Lw:"ffff00",LwgYF:"9acd32"};let Ht;function jt(t){Ht||(Ht=function(){const t={},e=Object.keys(Nt),i=Object.keys(Wt);let s,n,o,a,r;for(s=0;s<e.length;s++){for(a=r=e[s],n=0;n<i.length;n++)o=i[n],r=r.replace(o,Wt[o]);o=parseInt(Nt[a],16),t[r]=[o>>16&255,o>>8&255,255&o]}return t}(),Ht.transparent=[0,0,0,0]);const e=Ht[t.toLowerCase()];return e&&{r:e[0],g:e[1],b:e[2],a:4===e.length?e[3]:255}}const $t=/^rgba?\(\s*([-+.\d]+)(%)?[\s,]+([-+.e\d]+)(%)?[\s,]+([-+.e\d]+)(%)?(?:[\s,/]+([-+.e\d]+)(%)?)?\s*\)$/;const Yt=t=>t<=.0031308?12.92*t:1.055*Math.pow(t,1/2.4)-.055,Ut=t=>t<=.04045?t/12.92:Math.pow((t+.055)/1.055,2.4);function Xt(t,e,i){if(t){let s=It(t);s[e]=Math.max(0,Math.min(s[e]+s[e]*i,0===e?360:1)),Line truncated
//# sourceMappingURL=chart.umd.min.js.map
+44 -5
View File
@@ -25,6 +25,9 @@
<li class="nav-item">
<a class="nav-link" href="/admin/receipts">📸 Wszystkie paragony</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/admin/products">🛍️ Produkty</a>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle text-danger" href="#" id="clearDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
🗑️ Czyszczenie
@@ -38,7 +41,6 @@
</div>
</nav>
<div class="row g-3 mb-4">
<div class="col-md-4">
<div class="card bg-dark text-white h-100">
@@ -70,16 +72,17 @@
<div class="card bg-dark text-white h-100">
<div class="card-body">
<h5>💸 Podsumowanie wydatków:</h5>
<ul class="mb-0">
<ul class="mb-3">
<li><strong>Obecny miesiąc:</strong> {{ '%.2f'|format(month_expense_sum) }} PLN</li>
<li><strong>Obecny rok:</strong> {{ '%.2f'|format(year_expense_sum) }} PLN</li>
<li><strong>Całkowite:</strong> {{ '%.2f'|format(total_expense_sum) }} PLN</li>
</ul>
<button type="button" class="btn btn-outline-primary w-100 mt-3" data-bs-toggle="modal" data-bs-target="#expensesChartModal" id="loadExpensesBtn">
📊 Pokaż wykres wydatków
</button>
</div>
</div>
</div>
</div>
<h3 class="mt-4">📄 Wszystkie listy zakupowe</h3>
@@ -140,7 +143,7 @@
{% endif %}
</td>
<td class="d-flex flex-wrap gap-1">
<a href="{{ url_for('edit_list', list_id=l.id) }}" class="btn btn-sm btn-outline-primary">✏️ Edytuj liste</a>
<a href="{{ url_for('edit_list', list_id=l.id) }}" class="btn btn-sm btn-outline-primary">✏️ Edytuj</a>
<a href="{{ url_for('archive_list', list_id=l.id) }}" class="btn btn-sm btn-outline-secondary">📥 Archiwizuj</a>
<a href="{{ url_for('delete_list', list_id=l.id) }}" class="btn btn-sm btn-outline-danger">🗑️ Usuń</a>
</td>
@@ -153,14 +156,50 @@
<button type="submit" class="btn btn-danger mt-2">🗑️ Usuń zaznaczone listy</button>
</form>
<div class="modal fade" id="expensesChartModal" tabindex="-1" aria-labelledby="expensesChartModalLabel" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-centered">
<div class="modal-content bg-dark text-white rounded">
<div class="modal-header border-0">
<div>
<h5 class="modal-title m-0" id="expensesChartModalLabel">📊 Wydatki</h5>
<small id="chartRangeLabel" class="text-muted">Widok: miesięczne</small>
</div>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Zamknij"></button>
</div>
<div class="modal-body pt-0">
<div class="d-flex flex-wrap gap-2 mb-3">
<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="input-group input-group-sm mb-3 w-100" style="max-width: 570px;">
<span class="input-group-text bg-secondary text-white border-secondary">Od</span>
<input type="date" class="form-control bg-dark text-white border-secondary flex-grow-1" id="startDate">
<span class="input-group-text bg-secondary text-white border-secondary">Do</span>
<input type="date" class="form-control bg-dark text-white border-secondary flex-grow-1" id="endDate">
<button class="btn btn-outline-success" id="customRangeBtn">Pokaż dane z zakresu 📅</button>
</div>
<div class="bg-dark rounded p-2">
<canvas id="expensesChart" height="100"></canvas>
</div>
</div>
</div>
</div>
</div>
{% block scripts %}
<script src="{{ url_for('static_bp.serve_js_lib', filename='chart.js') }}"></script>
<script>
document.getElementById('select-all').addEventListener('click', function(){
const checkboxes = document.querySelectorAll('input[name="list_ids"]');
checkboxes.forEach(cb => cb.checked = this.checked);
});
</script>
<script src="{{ url_for('static_bp.serve_js', filename='expenses.js') }}"></script>
{% endblock %}
{% endblock %}
+62
View File
@@ -0,0 +1,62 @@
{% extends 'base.html' %}
{% block title %}Produkty i sugestie{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center flex-wrap mb-4">
<h2 class="mb-2">🛍️ Produkty i sugestie</h2>
<a href="/admin" class="btn btn-outline-secondary">← Powrót do panelu</a>
</div>
<div class="card bg-dark text-white">
<div class="card-header d-flex justify-content-between align-items-center">
<h4 class="m-0">📦 Produkty (z synchronizacją sugestii)</h4>
<span class="badge bg-secondary">{{ items|length }} produktów</span>
</div>
<div class="card-body p-0">
<table class="table table-dark table-striped align-middle m-0">
<thead>
<tr>
<th>ID</th>
<th>Nazwa</th>
<th>Dodana przez</th>
<th>Sugestia</th>
<th>Akcje</th>
</tr>
</thead>
<tbody>
{% for item in items %}
<tr>
<td>{{ item.id }}</td>
<td class="fw-bold">{{ item.name }}</td>
<td>
{% if item.added_by %}
{{ users_dict.get(item.added_by, 'Nieznany') }}
{% else %}
Gość
{% endif %}
</td>
<td>
{% set suggestion = suggestions_dict.get(item.name.lower()) %}
{% if suggestion %}
✅ Istnieje (ID: {{ suggestion.id }})
<a href="/admin/delete_suggestion/{{ suggestion.id }}" class="btn btn-sm btn-outline-danger ms-1">🗑️ Usuń</a>
{% else %}
<a href="/admin/sync_suggestion/{{ item.name }}" class="btn btn-sm btn-outline-primary">🔄 Synchronizuj</a>
{% endif %}
</td>
<td>
<a href="/list/{{ item.list_id }}" class="btn btn-sm btn-outline-light mb-1">📄 Zobacz listę</a>
</td>
</tr>
{% endfor %}
{% if items|length == 0 %}
<tr>
<td colspan="5" class="text-center text-muted">Brak produktów do wyświetlenia.</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</div>
{% endblock %}
+13 -14
View File
@@ -12,32 +12,32 @@ Lista: <strong>{{ list.title }}</strong>
<a href="/" class="btn btn-outline-secondary">← Powrót do list</a>
</div>
<a href="{{ request.url_root }}share/{{ list.share_token }}"
class="btn btn-primary btn-sm w-100 mb-3"
{% if not list.is_public %}disabled{% endif %}>
✅ Otwórz tryb zakupowy / odznaczania produktów
</a>
<div id="share-card" class="card bg-dark text-white mb-4">
<div class="card-body d-flex flex-column flex-md-row justify-content-between align-items-start align-items-md-center gap-2">
<div>
<div class="card-body">
<div class="mb-2">
<strong id="share-header">
{% if list.is_public %}
🔗 Udostępnij link:
{% else %}
🙈 Lista jest ukryta przed gośćmi
{% endif %}
</strong><br>
</strong>
<span id="share-url" class="badge bg-secondary text-wrap" style="font-size: 0.7rem; {% if not list.is_public %}display: none;{% endif %}">
{{ request.url_root }}share/{{ list.share_token }}
</span>
</div>
<div class="d-flex flex-column flex-md-row gap-2 mt-2 mt-md-0">
<button id="copyBtn" class="btn btn-success btn-sm"
<div class="d-flex flex-column flex-md-row gap-2">
<button id="copyBtn" class="btn btn-success btn-sm flex-fill"
onclick="copyLink('{{ request.url_root }}share/{{ list.share_token }}')"
{% if not list.is_public %}disabled{% endif %}>
📋 Skopiuj / Udostępnij
</button>
<button id="openBtn" class="btn btn-primary btn-sm"
onclick="openList('{{ request.url_root }}share/{{ list.share_token }}')"
{% if not list.is_public %}disabled{% endif %}>
✅ Otwórz do odznaczania
</button>
<button id="toggleVisibilityBtn" class="btn btn-outline-secondary btn-sm" onclick="toggleVisibility({{ list.id }})">
<button id="toggleVisibilityBtn" class="btn btn-outline-light btn-sm flex-fill" onclick="toggleVisibility({{ list.id }})">
{% if list.is_public %}
🙈 Ukryj listę
{% else %}
@@ -103,11 +103,10 @@ Lista: <strong>{{ list.title }}</strong>
{% if not list.is_archived %}
<div class="input-group mb-3">
<input type="text" id="newItem" name="name" class="form-control" placeholder="Dodaj produkt" required>
<input type="text" id="newItem" name="name" class="form-control" placeholder="Dodaj produkt i ilość" required>
<input type="number" id="newQuantity" name="quantity" class="form-control" placeholder="Ilość" min="1" value="1" style="max-width: 80px;">
<button type="button" class="btn btn-success rounded" onclick="addItem({{ list.id }})">➕ Dodaj</button>
<button type="button" class="btn btn-success rounded-end" onclick="addItem({{ list.id }})">➕ Dodaj</button>
</div>
{% endif %}
{% set receipt_pattern = 'list_' ~ list.id %}
+4 -4
View File
@@ -45,7 +45,7 @@
<div class="input-group mb-2">
<input id="newItem" class="form-control" placeholder="Dodaj produkt i ilość">
<input id="newQuantity" type="number" class="form-control" placeholder="Ilość" min="1" value="1" style="max-width: 90px;">
<button onclick="addItem({{ list.id }})" class="btn btn-success rounded">➕ Dodaj</button>
<button onclick="addItem({{ list.id }})" class="btn btn-success rounded-end">➕ Dodaj</button>
</div>
{% endif %}
@@ -54,7 +54,7 @@
<h5>💰 Dodaj wydatek</h5>
<div class="input-group mb-2">
<input id="expenseAmount" type="number" step="0.01" min="0" class="form-control" placeholder="Kwota (PLN)">
<button onclick="submitExpense({{ list.id }})" class="btn btn-success rounded">💾 Zapisz</button>
<button onclick="submitExpense({{ list.id }})" class="btn btn-success rounded-end">💾 Zapisz</button>
</div>
{% endif %}
<p id="total-expense2"><b>💸 Łącznie wydano:</b> {{ '%.2f'|format(total_expense) }} PLN</p>
@@ -67,7 +67,7 @@
{% for file in receipt_files %}
<div class="col-6 col-md-4 col-lg-3 text-center">
<a href="{{ url_for('uploaded_file', filename=file) }}" data-lightbox="receipt" data-title="Paragon">
<img src="{{ url_for('uploaded_file', filename=file) }}" class="img-fluid rounded shadow-sm border border-secondary" style="max-height: 200px; object-fit: cover;">
<img src="{{ url_for('uploaded_file', filename=file) }}" class="img-fluid rounded-end shadow-sm border border-secondary" style="max-height: 200px; object-fit: cover;">
</a>
</div>
{% endfor %}
@@ -83,7 +83,7 @@
<form action="{{ url_for('upload_receipt', list_id=list.id) }}" method="post" enctype="multipart/form-data">
<div class="input-group mb-2">
<input type="file" name="receipt" accept="image/*" capture="environment" class="form-control custom-file-input" id="receiptInput">
<button type="submit" class="btn btn-success rounded">➕ Wgraj</button>
<button type="submit" class="btn btn-success rounded-end">➕ Wgraj</button>
</div>
</form>
{% endif %}
+74 -28
View File
@@ -9,28 +9,40 @@
{% endif %}
{% if current_user.is_authenticated %}
<div class="d-flex justify-content-between align-items-center flex-wrap mb-4">
<h2 class="mb-2">Stwórz nową listę</h2>
</div>
<div class="d-flex justify-content-between align-items-center flex-wrap mb-4">
<h2 class="mb-2">Stwórz nową listę</h2>
</div>
<div class="card bg-dark text-white mb-4">
<div class="card-body">
<form action="/create" method="post">
<div class="input-group mb-3">
<input type="text" name="title" id="title" placeholder="Wprowadź nazwę nowej listy" required class="form-control">
<div class="input-group-text">
<input type="checkbox" name="temporary" class="form-check-input m-0" id="tempCheck">
<label for="tempCheck" class="ms-2 mb-0">Tymczasowa (7 dni)</label>
</div>
</div>
<button type="submit" class="btn btn-success w-100">➕ Utwórz nową listę</button>
</form>
</div>
<div class="card bg-dark text-white mb-4">
<div class="card-body">
<form action="/create" method="post">
<div class="input-group mb-3">
<input type="text" name="title" id="title" placeholder="Wprowadź nazwę nowej listy" required class="form-control">
<button
type="button"
class="btn btn-outline-secondary rounded-end"
id="tempToggle"
data-active="0"
data-bs-toggle="tooltip"
data-bs-placement="top"
title="Po włączeniu lista będzie ważna tylko 7 dni">
Tymczasowa
</button>
<input type="hidden" name="temporary" id="temporaryHidden" value="0">
</div>
<button type="submit" class="btn btn-success w-100">➕ Utwórz nową listę</button>
</form>
</div>
</div>
{% endif %}
{% if current_user.is_authenticated %}
<h3 class="mt-4">Twoje listy</h3>
<h3 class="mt-4 d-flex justify-content-between align-items-center flex-wrap">
Twoje listy
<button type="button" class="btn btn-sm btn-outline-light ms-2" data-bs-toggle="modal" data-bs-target="#archivedModal">
📁 Zarchiwizowane
</button>
</h3>
{% if user_lists %}
<ul class="list-group mb-4">
{% for l in user_lists %}
@@ -40,17 +52,18 @@
<li class="list-group-item bg-dark text-white">
<div class="d-flex justify-content-between align-items-center flex-wrap w-100">
<span class="fw-bold">{{ l.title }} (Autor: Ty)</span>
<div class="mt-2 mt-md-0">
<a href="/list/{{ l.id }}" class="btn btn-sm btn-outline-light me-1">📄 Otwórz</a>
<a href="/copy/{{ l.id }}" class="btn btn-sm btn-outline-secondary me-1">📋 Kopiuj</a>
<a href="/edit_my_list/{{ l.id }}" class="btn btn-sm btn-outline-warning me-1">✏️ Edytuj</a>
<a href="/archive_my_list/{{ l.id }}" class="btn btn-sm btn-outline-danger me-1">🗄️ Archiwizuj</a>
{% if l.is_public %}
<a href="/toggle_visibility/{{ l.id }}" class="btn btn-sm btn-outline-secondary">🙈 Ukryj</a>
{% else %}
<a href="/toggle_visibility/{{ l.id }}" class="btn btn-sm btn-outline-success">👁️ Udostępnij</a>
{% endif %}
</div>
<div class="d-flex flex-wrap mt-2 mt-md-0">
<a href="/list/{{ l.id }}" class="btn btn-sm btn-outline-light me-1 mb-1">📄 Otwórz</a>
<a href="/copy/{{ l.id }}" class="btn btn-sm btn-outline-light me-1 mb-1">📋 Kopiuj</a>
<a href="/edit_my_list/{{ l.id }}" class="btn btn-sm btn-outline-light me-1 mb-1">✏️ Edytuj</a>
<a href="/toggle_archive_list/{{ l.id }}?archive=true" class="btn btn-sm btn-outline-light me-1 mb-1">🗄️ Archiwizuj</a>
{% if l.is_public %}
<a href="/toggle_visibility/{{ l.id }}" class="btn btn-sm btn-outline-light me-1 mb-1">🙈 Ukryj</a>
{% else %}
<a href="/toggle_visibility/{{ l.id }}" class="btn btn-sm btn-outline-light me-1 mb-1">👁️ Udostępnij</a>
{% endif %}
</div>
</div>
<div class="progress mt-2" style="height: 20px;">
<div class="progress-bar bg-warning text-dark fw-bold" role="progressbar" style="width: {{ percent }}%" aria-valuenow="{{ percent }}" aria-valuemin="0" aria-valuemax="100">
@@ -95,4 +108,37 @@
<p><span class="badge bg-secondary">Brak dostępnych list publicznych do wyświetlenia.</span></p>
{% endif %}
<div class="modal fade" id="archivedModal" tabindex="-1" aria-labelledby="archivedModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content bg-dark text-white">
<div class="modal-header">
<h5 class="modal-title" id="archivedModalLabel">Zarchiwizowane listy</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Zamknij"></button>
</div>
<div class="modal-body">
{% if archived_lists %}
<ul class="list-group">
{% for l in archived_lists %}
<li class="list-group-item bg-dark text-white d-flex justify-content-between align-items-center flex-wrap">
<span>{{ l.title }}</span>
<a href="/toggle_archive_list/{{ l.id }}?archive=false" class="btn btn-sm btn-outline-success">♻️ Przywróć</a>
</li>
{% endfor %}
</ul>
{% else %}
<p><span class="badge bg-secondary">Nie masz żadnych zarchiwizowanych list.</span></p>
{% endif %}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-light" data-bs-dismiss="modal">Zamknij</button>
</div>
</div>
</div>
</div>
{% block scripts %}
<script src="{{ url_for('static_bp.serve_js', filename='toggle_button.js') }}"></script>
{% endblock %}
{% endblock %}