Compare commits
24 Commits
e5e498a5a9
...
v0.0.4
Author | SHA1 | Date | |
---|---|---|---|
67d4fd0024 | |||
e1d1ec67c3 | |||
a81737b2ce | |||
![]() |
40a3d60da0 | ||
![]() |
9a844fc539 | ||
![]() |
396a56e773 | ||
![]() |
c6b089472a | ||
![]() |
1de3171183 | ||
![]() |
18e2d376c2 | ||
![]() |
159b52099e | ||
![]() |
643757e45e | ||
![]() |
9e3068a722 | ||
![]() |
b9b91ff82b | ||
![]() |
a5025b94ff | ||
![]() |
5c6e2f6540 | ||
![]() |
f913aeac60 | ||
![]() |
359b5fb61b | ||
![]() |
5519f7eef5 | ||
![]() |
4b76df795b | ||
![]() |
81985f7f84 | ||
![]() |
50d67d5b1a | ||
![]() |
452f2271cd | ||
7812209818 | |||
1d583ad801 |
514
app.py
514
app.py
@@ -64,18 +64,19 @@ app = Flask(__name__)
|
||||
app.config.from_object(Config)
|
||||
|
||||
# Konfiguracja nagłówków bezpieczeństwa z .env
|
||||
csp_policy = None
|
||||
if app.config.get("ENABLE_CSP", True):
|
||||
csp_policy = {
|
||||
csp_policy = (
|
||||
{
|
||||
"default-src": "'self'",
|
||||
"script-src": "'self'", # wciąż bez inline JS
|
||||
"style-src": "'self' 'unsafe-inline'", # dopuszczamy style w HTML-u
|
||||
"img-src": "'self' data:", # pozwalamy na data:image (np. SVG)
|
||||
"connect-src": "'self'", # WebSockety
|
||||
"script-src": "'self' 'unsafe-inline'",
|
||||
"style-src": "'self' 'unsafe-inline'",
|
||||
"img-src": "'self' data:",
|
||||
"connect-src": "'self'",
|
||||
}
|
||||
if app.config.get("ENABLE_CSP", True)
|
||||
else None
|
||||
)
|
||||
|
||||
permissions_policy = {"browsing-topics": "()"} if app.config["ENABLE_PP"] else None
|
||||
permissions_policy = {"browsing-topics": "()"} if app.config.get("ENABLE_PP") else None
|
||||
|
||||
talisman_kwargs = {
|
||||
"force_https": False,
|
||||
@@ -85,11 +86,12 @@ talisman_kwargs = {
|
||||
"content_security_policy": csp_policy,
|
||||
"x_content_type_options": app.config.get("ENABLE_XCTO", True),
|
||||
"strict_transport_security_include_subdomains": False,
|
||||
"session_cookie_secure": app.config["SESSION_COOKIE_SECURE"],
|
||||
"session_cookie_secure": app.config.get("SESSION_COOKIE_SECURE", False),
|
||||
}
|
||||
|
||||
if app.config.get("REFERRER_POLICY"):
|
||||
talisman_kwargs["referrer_policy"] = app.config["REFERRER_POLICY"]
|
||||
referrer_policy = app.config.get("REFERRER_POLICY")
|
||||
if referrer_policy:
|
||||
talisman_kwargs["referrer_policy"] = referrer_policy
|
||||
|
||||
talisman = Talisman(app, **talisman_kwargs)
|
||||
|
||||
@@ -118,6 +120,14 @@ failed_login_attempts = defaultdict(deque)
|
||||
MAX_ATTEMPTS = 10
|
||||
TIME_WINDOW = 60 * 60
|
||||
|
||||
WEBP_SAVE_PARAMS = {
|
||||
"format": "WEBP",
|
||||
"lossless": True, # lub False jeśli chcesz używać quality
|
||||
"method": 6,
|
||||
# "quality": 95, # tylko jeśli lossless=False
|
||||
}
|
||||
|
||||
|
||||
db = SQLAlchemy(app)
|
||||
socketio = SocketIO(app, async_mode="eventlet")
|
||||
login_manager = LoginManager(app)
|
||||
@@ -172,7 +182,7 @@ class Item(db.Model):
|
||||
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]
|
||||
"User", backref="added_items", lazy="joined", foreign_keys=[added_by]
|
||||
)
|
||||
|
||||
purchased = db.Column(db.Boolean, default=False)
|
||||
@@ -232,9 +242,9 @@ with app.app_context():
|
||||
@static_bp.route("/static/js/<path:filename>")
|
||||
def serve_js(filename):
|
||||
response = send_from_directory("static/js", filename)
|
||||
#response.cache_control.no_cache = True
|
||||
#response.cache_control.no_store = True
|
||||
#response.cache_control.must_revalidate = True
|
||||
# response.cache_control.no_cache = True
|
||||
# response.cache_control.no_store = True
|
||||
# response.cache_control.must_revalidate = True
|
||||
response.headers["Cache-Control"] = app.config["JS_CACHE_CONTROL"]
|
||||
response.headers.pop("Content-Disposition", None)
|
||||
response.headers.pop("Etag", None)
|
||||
@@ -330,8 +340,7 @@ def save_resized_image(file, path):
|
||||
image.info.clear()
|
||||
|
||||
new_path = path.rsplit(".", 1)[0] + ".webp"
|
||||
# image.save(new_path, format="WEBP", quality=100, method=0)
|
||||
image.save(new_path, format="WEBP", lossless=True, method=6)
|
||||
image.save(new_path, **WEBP_SAVE_PARAMS)
|
||||
|
||||
except Exception as e:
|
||||
raise ValueError(f"Błąd podczas przetwarzania obrazu: {e}")
|
||||
@@ -392,7 +401,7 @@ def rotate_receipt_by_id(receipt_id):
|
||||
|
||||
new_filename = generate_new_receipt_filename(receipt.list_id)
|
||||
new_path = os.path.join(app.config["UPLOAD_FOLDER"], new_filename)
|
||||
rotated.save(new_path, format="WEBP", quality=100)
|
||||
rotated.save(new_path, **WEBP_SAVE_PARAMS)
|
||||
|
||||
os.remove(old_path)
|
||||
receipt.filename = new_filename
|
||||
@@ -419,6 +428,131 @@ def generate_new_receipt_filename(list_id):
|
||||
return f"list_{list_id}_{timestamp}_{random_part}.webp"
|
||||
|
||||
|
||||
def handle_crop_receipt(receipt_id, file):
|
||||
if not receipt_id or not file:
|
||||
return {"success": False, "error": "Brak danych"}
|
||||
|
||||
receipt = Receipt.query.get_or_404(receipt_id)
|
||||
old_path = os.path.join(app.config["UPLOAD_FOLDER"], receipt.filename)
|
||||
|
||||
try:
|
||||
new_filename = generate_new_receipt_filename(receipt.list_id)
|
||||
new_path = os.path.join(app.config["UPLOAD_FOLDER"], new_filename)
|
||||
|
||||
save_resized_image(file, new_path)
|
||||
|
||||
if os.path.exists(old_path):
|
||||
os.remove(old_path)
|
||||
|
||||
receipt.filename = os.path.basename(new_path)
|
||||
db.session.commit()
|
||||
recalculate_filesizes(receipt.id)
|
||||
return {"success": True}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
|
||||
def get_expenses_aggregated_by_list_created_at(
|
||||
user_only=False,
|
||||
admin=False,
|
||||
show_all=False,
|
||||
range_type="monthly",
|
||||
start_date=None,
|
||||
end_date=None,
|
||||
user_id=None,
|
||||
):
|
||||
"""
|
||||
Wspólna logika: sumujemy najnowszy wydatek z każdej listy,
|
||||
ale do agregacji/filtra bierzemy ShoppingList.created_at!
|
||||
"""
|
||||
lists_query = ShoppingList.query
|
||||
# Uprawnienia
|
||||
if admin:
|
||||
# admin widzi wszystko, ewentualnie: dodać filtrowanie wg potrzeb
|
||||
pass
|
||||
elif show_all:
|
||||
lists_query = lists_query.filter(
|
||||
or_(
|
||||
ShoppingList.owner_id == user_id,
|
||||
ShoppingList.is_public == True,
|
||||
)
|
||||
)
|
||||
else:
|
||||
lists_query = lists_query.filter(ShoppingList.owner_id == user_id)
|
||||
|
||||
# Filtrowanie po created_at listy
|
||||
if start_date and end_date:
|
||||
try:
|
||||
dt_start = datetime.strptime(start_date, "%Y-%m-%d")
|
||||
dt_end = datetime.strptime(end_date, "%Y-%m-%d") + timedelta(days=1)
|
||||
except Exception:
|
||||
return {"error": "Błędne daty", "labels": [], "expenses": []}
|
||||
lists_query = lists_query.filter(
|
||||
ShoppingList.created_at >= dt_start, ShoppingList.created_at < dt_end
|
||||
)
|
||||
lists = lists_query.all()
|
||||
|
||||
# Najnowszy wydatek każdej listy
|
||||
data = []
|
||||
for sl in lists:
|
||||
latest_exp = (
|
||||
Expense.query.filter_by(list_id=sl.id)
|
||||
.order_by(Expense.added_at.desc())
|
||||
.first()
|
||||
)
|
||||
if latest_exp:
|
||||
data.append({"created_at": sl.created_at, "amount": latest_exp.amount})
|
||||
|
||||
# Grupowanie po wybranym zakresie wg utworzenia listy
|
||||
grouped = defaultdict(float)
|
||||
for rec in data:
|
||||
ts = rec["created_at"] or datetime.now(timezone.utc)
|
||||
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] += rec["amount"]
|
||||
|
||||
labels = sorted(grouped)
|
||||
expenses = [round(grouped[l], 2) for l in labels]
|
||||
return {"labels": labels, "expenses": expenses}
|
||||
|
||||
|
||||
def recalculate_filesizes(receipt_id: int = None):
|
||||
updated = 0
|
||||
not_found = 0
|
||||
unchanged = 0
|
||||
|
||||
if receipt_id is not None:
|
||||
receipt = db.session.get(Receipt, receipt_id)
|
||||
receipts = [receipt] if receipt else []
|
||||
else:
|
||||
receipts = db.session.execute(db.select(Receipt)).scalars().all()
|
||||
|
||||
for r in receipts:
|
||||
if not r:
|
||||
continue
|
||||
filepath = os.path.join(app.config["UPLOAD_FOLDER"], r.filename)
|
||||
if os.path.exists(filepath):
|
||||
real_size = os.path.getsize(filepath)
|
||||
if r.filesize != real_size:
|
||||
r.filesize = real_size
|
||||
updated += 1
|
||||
else:
|
||||
unchanged += 1
|
||||
else:
|
||||
not_found += 1
|
||||
|
||||
db.session.commit()
|
||||
return updated, unchanged, not_found
|
||||
|
||||
|
||||
############# OCR ###########################
|
||||
|
||||
|
||||
@@ -556,6 +690,7 @@ def attempts_remaining(ip):
|
||||
|
||||
####################################################
|
||||
|
||||
|
||||
def get_client_ip():
|
||||
# Obsługuje: X-Forwarded-For, X-Real-IP, fallback na remote_addr
|
||||
for header in ["X-Forwarded-For", "X-Real-IP"]:
|
||||
@@ -566,6 +701,7 @@ def get_client_ip():
|
||||
return ip
|
||||
return request.remote_addr
|
||||
|
||||
|
||||
@login_manager.user_loader
|
||||
def load_user(user_id):
|
||||
# return User.query.get(int(user_id))
|
||||
@@ -652,7 +788,7 @@ def log_request(response):
|
||||
duration = round((time.time() - start) * 1000, 2) if start else "-"
|
||||
agent = request.headers.get("User-Agent", "-")
|
||||
|
||||
log_msg = f"{ip} - \"{method} {path}\" {status} {length} {duration}ms \"{agent}\""
|
||||
log_msg = f'{ip} - "{method} {path}" {status} {length} {duration}ms "{agent}"'
|
||||
app.logger.info(log_msg)
|
||||
return response
|
||||
|
||||
@@ -839,7 +975,7 @@ def system_auth():
|
||||
"authorized",
|
||||
AUTHORIZED_COOKIE_VALUE,
|
||||
max_age=max_age,
|
||||
secure=request.is_secure
|
||||
secure=request.is_secure,
|
||||
)
|
||||
return resp
|
||||
else:
|
||||
@@ -897,15 +1033,31 @@ def edit_my_list(list_id):
|
||||
abort(403, description="Nie jesteś właścicielem tej listy.")
|
||||
|
||||
if request.method == "POST":
|
||||
# Obsługa zmiany miesiąca utworzenia listy
|
||||
move_to_month = request.form.get("move_to_month")
|
||||
if move_to_month:
|
||||
try:
|
||||
year, month = map(int, move_to_month.split("-"))
|
||||
new_created_at = datetime(year, month, 1, tzinfo=timezone.utc)
|
||||
l.created_at = new_created_at
|
||||
db.session.commit()
|
||||
flash(
|
||||
f"Zmieniono datę utworzenia listy na {new_created_at.strftime('%Y-%m-%d')}",
|
||||
"success",
|
||||
)
|
||||
return redirect(url_for("edit_my_list", list_id=list_id))
|
||||
except ValueError:
|
||||
flash("Nieprawidłowy format miesiąca", "danger")
|
||||
return redirect(url_for("edit_my_list", list_id=list_id))
|
||||
|
||||
# Pozostała aktualizacja pól
|
||||
new_title = request.form.get("title", "").strip()
|
||||
is_public = "is_public" in request.form
|
||||
is_temporary = "is_temporary" in request.form
|
||||
is_archived = "is_archived" in request.form
|
||||
|
||||
expires_date = request.form.get("expires_date")
|
||||
expires_time = request.form.get("expires_time")
|
||||
|
||||
# Walidacja tytułu
|
||||
if not new_title:
|
||||
flash("Podaj poprawny tytuł", "danger")
|
||||
return redirect(url_for("edit_my_list", list_id=list_id))
|
||||
@@ -915,7 +1067,6 @@ def edit_my_list(list_id):
|
||||
l.is_temporary = is_temporary
|
||||
l.is_archived = is_archived
|
||||
|
||||
# Obsługa daty wygaśnięcia
|
||||
if expires_date and expires_time:
|
||||
try:
|
||||
combined = f"{expires_date} {expires_time}"
|
||||
@@ -993,14 +1144,13 @@ def login():
|
||||
if user and check_password_hash(user.password_hash, request.form["password"]):
|
||||
session.permanent = True
|
||||
login_user(user)
|
||||
#session["logged"] = True
|
||||
# session["logged"] = True
|
||||
flash("Zalogowano pomyślnie", "success")
|
||||
return redirect(url_for("main_page"))
|
||||
flash("Nieprawidłowy login lub hasło", "danger")
|
||||
return render_template("login.html")
|
||||
|
||||
|
||||
|
||||
@app.route("/logout")
|
||||
@login_required
|
||||
def logout():
|
||||
@@ -1043,6 +1193,13 @@ def view_list(list_id):
|
||||
total_count = len(items)
|
||||
purchased_count = len([i for i in items if i.purchased])
|
||||
percent = (purchased_count / total_count * 100) if total_count > 0 else 0
|
||||
is_owner = current_user.id == shopping_list.owner_id
|
||||
|
||||
for item in items:
|
||||
if item.added_by != shopping_list.owner_id:
|
||||
item.added_by_display = item.added_by_user.username if item.added_by_user else "?"
|
||||
else:
|
||||
item.added_by_display = None
|
||||
|
||||
return render_template(
|
||||
"list.html",
|
||||
@@ -1055,6 +1212,7 @@ def view_list(list_id):
|
||||
expenses=expenses,
|
||||
total_expense=total_expense,
|
||||
is_share=False,
|
||||
is_owner=is_owner,
|
||||
)
|
||||
|
||||
|
||||
@@ -1141,48 +1299,18 @@ def user_expenses_data():
|
||||
end_date = request.args.get("end_date")
|
||||
show_all = request.args.get("show_all", "false").lower() == "true"
|
||||
|
||||
query = Expense.query.join(ShoppingList, Expense.list_id == ShoppingList.id)
|
||||
|
||||
if show_all:
|
||||
query = query.filter(
|
||||
or_(
|
||||
ShoppingList.owner_id == current_user.id, ShoppingList.is_public == True
|
||||
)
|
||||
)
|
||||
else:
|
||||
query = query.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.added_at >= start, Expense.added_at < 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()
|
||||
ts = e.added_at or datetime.now(timezone.utc)
|
||||
|
||||
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})
|
||||
result = get_expenses_aggregated_by_list_created_at(
|
||||
user_only=True,
|
||||
admin=False,
|
||||
show_all=show_all,
|
||||
range_type=range_type,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
user_id=current_user.id,
|
||||
)
|
||||
if "error" in result:
|
||||
return jsonify({"error": result["error"]}), 400
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@app.route("/share/<token>")
|
||||
@@ -1200,6 +1328,12 @@ def shared_list(token=None, list_id=None):
|
||||
list_id
|
||||
)
|
||||
|
||||
for item in items:
|
||||
if item.added_by != shopping_list.owner_id:
|
||||
item.added_by_display = item.added_by_user.username if item.added_by_user else "?"
|
||||
else:
|
||||
item.added_by_display = None
|
||||
|
||||
return render_template(
|
||||
"list_share.html",
|
||||
list=shopping_list,
|
||||
@@ -1393,6 +1527,7 @@ def rotate_receipt_user(receipt_id):
|
||||
|
||||
try:
|
||||
rotate_receipt_by_id(receipt_id)
|
||||
recalculate_filesizes(receipt_id)
|
||||
flash("Obrócono paragon", "success")
|
||||
except FileNotFoundError:
|
||||
flash("Plik nie istnieje", "danger")
|
||||
@@ -1468,6 +1603,22 @@ def analyze_receipts_for_list(list_id):
|
||||
return jsonify({"results": results, "total": round(total, 2)})
|
||||
|
||||
|
||||
@app.route("/user_crop_receipt", methods=["POST"])
|
||||
@login_required
|
||||
def crop_receipt_user():
|
||||
receipt_id = request.form.get("receipt_id")
|
||||
file = request.files.get("cropped_image")
|
||||
|
||||
receipt = Receipt.query.get_or_404(receipt_id)
|
||||
list_obj = ShoppingList.query.get_or_404(receipt.list_id)
|
||||
|
||||
if list_obj.owner_id != current_user.id and not current_user.is_admin:
|
||||
return jsonify(success=False, error="Brak dostępu"), 403
|
||||
|
||||
result = handle_crop_receipt(receipt_id, file)
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@app.route("/admin")
|
||||
@login_required
|
||||
@admin_required
|
||||
@@ -1488,10 +1639,8 @@ def admin_panel():
|
||||
purchased_count = l.purchased_count
|
||||
percent = (purchased_count / total_count * 100) if total_count > 0 else 0
|
||||
comments_count = len([i for i in items if i.note and i.note.strip() != ""])
|
||||
receipt_pattern = f"list_{l.id}"
|
||||
receipt_files = [f for f in all_files if receipt_pattern in f]
|
||||
receipts_count = Receipt.query.filter_by(list_id=l.id).count()
|
||||
|
||||
# obliczenie czy wygasła
|
||||
if l.is_temporary and l.expires_at:
|
||||
expires_at = l.expires_at
|
||||
if expires_at.tzinfo is None:
|
||||
@@ -1507,7 +1656,7 @@ def admin_panel():
|
||||
"purchased_count": purchased_count,
|
||||
"percent": round(percent),
|
||||
"comments_count": comments_count,
|
||||
"receipts_count": len(receipt_files),
|
||||
"receipts_count": receipts_count,
|
||||
"total_expense": l.total_expense,
|
||||
"expired": is_expired,
|
||||
}
|
||||
@@ -1694,6 +1843,18 @@ def admin_receipts(id):
|
||||
try:
|
||||
if id == "all":
|
||||
receipts = Receipt.query.order_by(Receipt.uploaded_at.desc()).all()
|
||||
|
||||
# Szukaj sierot tylko dla "all"
|
||||
upload_folder = app.config["UPLOAD_FOLDER"]
|
||||
all_db_filenames = set(r.filename for r in receipts)
|
||||
files_on_disk = set(os.listdir(upload_folder))
|
||||
stale_files = [
|
||||
f
|
||||
for f in files_on_disk
|
||||
if f.endswith(".webp")
|
||||
and f not in all_db_filenames
|
||||
and f.startswith("list_")
|
||||
]
|
||||
else:
|
||||
list_id = int(id)
|
||||
receipts = (
|
||||
@@ -1701,11 +1862,17 @@ def admin_receipts(id):
|
||||
.order_by(Receipt.uploaded_at.desc())
|
||||
.all()
|
||||
)
|
||||
stale_files = [] # brak sierot
|
||||
except ValueError:
|
||||
flash("Nieprawidłowe ID listy.", "danger")
|
||||
return redirect(url_for("admin_panel"))
|
||||
|
||||
return render_template("admin/receipts.html", receipts=receipts)
|
||||
return render_template(
|
||||
"admin/receipts.html",
|
||||
receipts=receipts,
|
||||
orphan_files=stale_files,
|
||||
orphan_files_count=len(stale_files),
|
||||
)
|
||||
|
||||
|
||||
@app.route("/admin/rotate_receipt/<int:receipt_id>")
|
||||
@@ -1714,6 +1881,7 @@ def admin_receipts(id):
|
||||
def rotate_receipt(receipt_id):
|
||||
try:
|
||||
rotate_receipt_by_id(receipt_id)
|
||||
recalculate_filesizes(receipt_id)
|
||||
flash("Obrócono paragon", "success")
|
||||
except FileNotFoundError:
|
||||
flash("Plik nie istnieje", "danger")
|
||||
@@ -1724,9 +1892,27 @@ def rotate_receipt(receipt_id):
|
||||
|
||||
|
||||
@app.route("/admin/delete_receipt/<int:receipt_id>")
|
||||
@app.route("/admin/delete_receipt/orphan/<path:filename>")
|
||||
@login_required
|
||||
@admin_required
|
||||
def delete_receipt(receipt_id):
|
||||
def delete_receipt(receipt_id=None, filename=None):
|
||||
if filename: # tryb orphan
|
||||
safe_filename = os.path.basename(filename)
|
||||
if Receipt.query.filter_by(filename=safe_filename).first():
|
||||
flash("Nie można usunąć pliku powiązanego z bazą!", "danger")
|
||||
else:
|
||||
file_path = os.path.join(app.config["UPLOAD_FOLDER"], safe_filename)
|
||||
if os.path.exists(file_path):
|
||||
try:
|
||||
os.remove(file_path)
|
||||
flash(f"Usunięto plik: {safe_filename}", "success")
|
||||
except Exception as e:
|
||||
flash(f"Błąd przy usuwaniu pliku: {e}", "danger")
|
||||
else:
|
||||
flash("Plik już nie istnieje.", "warning")
|
||||
return redirect(url_for("admin_receipts", id="all"))
|
||||
|
||||
# tryb z rekordem w bazie
|
||||
try:
|
||||
delete_receipt_by_id(receipt_id)
|
||||
flash("Paragon usunięty", "success")
|
||||
@@ -1753,6 +1939,8 @@ def rename_receipt(receipt_id):
|
||||
try:
|
||||
os.rename(old_path, new_path)
|
||||
receipt.filename = new_filename
|
||||
db.session.flush()
|
||||
recalculate_filesizes(receipt.id)
|
||||
db.session.commit()
|
||||
flash("Zmieniono nazwę pliku", "success")
|
||||
except Exception as e:
|
||||
@@ -1884,6 +2072,18 @@ def edit_list(list_id):
|
||||
flash("Niepoprawna kwota", "danger")
|
||||
return redirect(url_for("edit_list", list_id=list_id))
|
||||
|
||||
created_month = request.form.get("created_month")
|
||||
if created_month:
|
||||
try:
|
||||
year, month = map(int, created_month.split("-"))
|
||||
l.created_at = datetime(year, month, 1, tzinfo=timezone.utc)
|
||||
except ValueError:
|
||||
flash(
|
||||
"Nieprawidłowy format miesiąca (przeniesienie daty utworzenia)",
|
||||
"danger",
|
||||
)
|
||||
return redirect(url_for("edit_list", list_id=list_id))
|
||||
|
||||
db.session.add(l)
|
||||
db.session.commit()
|
||||
flash("Zapisano zmiany listy", "success")
|
||||
@@ -2066,110 +2266,21 @@ def admin_expenses_data():
|
||||
return jsonify({"error": "Brak uprawnień"}), 403
|
||||
|
||||
range_type = request.args.get("range", "monthly")
|
||||
start_date_str = request.args.get("start_date")
|
||||
end_date_str = request.args.get("end_date")
|
||||
start_date = request.args.get("start_date")
|
||||
end_date = request.args.get("end_date")
|
||||
|
||||
# now = datetime.utcnow()
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
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
|
||||
result = get_expenses_aggregated_by_list_created_at(
|
||||
user_only=False,
|
||||
admin=True,
|
||||
show_all=True,
|
||||
range_type=range_type,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
user_id=None,
|
||||
)
|
||||
if "error" in result:
|
||||
return jsonify({"error": result["error"]}), 400
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@app.route("/admin/promote_user/<int:user_id>")
|
||||
@@ -2210,61 +2321,25 @@ def demote_user(user_id):
|
||||
@app.route("/admin/crop_receipt", methods=["POST"])
|
||||
@login_required
|
||||
@admin_required
|
||||
def crop_receipt():
|
||||
def crop_receipt_admin():
|
||||
receipt_id = request.form.get("receipt_id")
|
||||
file = request.files.get("cropped_image")
|
||||
|
||||
if not receipt_id or not file:
|
||||
return jsonify(success=False, error="Brak danych")
|
||||
|
||||
receipt = Receipt.query.get_or_404(receipt_id)
|
||||
old_path = os.path.join(app.config["UPLOAD_FOLDER"], receipt.filename)
|
||||
|
||||
try:
|
||||
new_filename = generate_new_receipt_filename(receipt.list_id)
|
||||
new_path = os.path.join(app.config["UPLOAD_FOLDER"], new_filename)
|
||||
|
||||
save_resized_image(file, new_path)
|
||||
|
||||
if os.path.exists(old_path):
|
||||
os.remove(old_path)
|
||||
|
||||
receipt.filename = os.path.basename(new_path)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify(success=True)
|
||||
except Exception as e:
|
||||
return jsonify(success=False, error=str(e))
|
||||
result = handle_crop_receipt(receipt_id, file)
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@app.route("/admin/recalculate_filesizes")
|
||||
@login_required
|
||||
@admin_required
|
||||
def recalculate_filesizes():
|
||||
updated = 0
|
||||
not_found = 0
|
||||
unchanged = 0
|
||||
|
||||
receipts = Receipt.query.all()
|
||||
for r in receipts:
|
||||
filepath = os.path.join(app.config["UPLOAD_FOLDER"], r.filename)
|
||||
if os.path.exists(filepath):
|
||||
real_size = os.path.getsize(filepath)
|
||||
if r.filesize != real_size:
|
||||
r.filesize = real_size
|
||||
updated += 1
|
||||
else:
|
||||
unchanged += 1
|
||||
else:
|
||||
not_found += 1
|
||||
|
||||
db.session.commit()
|
||||
def recalculate_filesizes_all():
|
||||
updated, unchanged, not_found = recalculate_filesizes()
|
||||
flash(
|
||||
f"Zaktualizowano: {updated}, bez zmian: {unchanged}, brak pliku: {not_found}",
|
||||
"success",
|
||||
)
|
||||
return redirect(url_for("admin_receipts", id="all"))
|
||||
|
||||
|
||||
@app.route("/healthcheck")
|
||||
def healthcheck():
|
||||
header_token = request.headers.get("X-Internal-Check")
|
||||
@@ -2274,6 +2349,7 @@ def healthcheck():
|
||||
abort(404)
|
||||
return "OK", 200
|
||||
|
||||
|
||||
@app.route("/robots.txt")
|
||||
def robots_txt():
|
||||
if app.config.get("DISABLE_ROBOTS", False):
|
||||
@@ -2665,4 +2741,4 @@ def create_db():
|
||||
|
||||
if __name__ == "__main__":
|
||||
logging.basicConfig(level=logging.DEBUG if DEBUG_MODE else logging.INFO)
|
||||
socketio.run(app, host="0.0.0.0", port=8000, debug=DEBUG_MODE)
|
||||
socketio.run(app, host="0.0.0.0", port=8000, debug=False)
|
||||
|
39
static/js/admin_receipt_crop.js
Normal file
39
static/js/admin_receipt_crop.js
Normal file
@@ -0,0 +1,39 @@
|
||||
(function () {
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
const cropModal = document.getElementById("adminCropModal");
|
||||
const cropImage = document.getElementById("adminCropImage");
|
||||
const spinner = document.getElementById("adminCropLoading");
|
||||
const saveButton = document.getElementById("adminSaveCrop");
|
||||
|
||||
if (!cropModal || !cropImage || !spinner || !saveButton) return;
|
||||
|
||||
let cropper;
|
||||
let currentReceiptId;
|
||||
const currentEndpoint = "/admin/crop_receipt";
|
||||
|
||||
cropModal.addEventListener("shown.bs.modal", function (event) {
|
||||
const button = event.relatedTarget;
|
||||
const imgSrc = button.getAttribute("data-img-src");
|
||||
currentReceiptId = button.getAttribute("data-receipt-id");
|
||||
cropImage.src = imgSrc;
|
||||
|
||||
document.querySelectorAll('.cropper-container').forEach(e => e.remove());
|
||||
|
||||
if (cropper) cropper.destroy();
|
||||
cropImage.onload = () => {
|
||||
cropper = cropUtils.initCropper(cropImage);
|
||||
};
|
||||
});
|
||||
|
||||
cropModal.addEventListener("hidden.bs.modal", function () {
|
||||
cropUtils.cleanUpCropper(cropImage, cropper);
|
||||
cropper = null;
|
||||
});
|
||||
|
||||
saveButton.addEventListener("click", function () {
|
||||
if (!cropper) return;
|
||||
spinner.classList.remove("d-none");
|
||||
cropUtils.handleCrop(currentEndpoint, currentReceiptId, cropper, spinner);
|
||||
});
|
||||
});
|
||||
})();
|
@@ -281,6 +281,9 @@ function renderItem(item, isShare = window.IS_SHARE, showEditOnly = false) {
|
||||
: 'item-not-checked'
|
||||
}`;
|
||||
|
||||
const isOwner = window.IS_OWNER === true || window.IS_OWNER === 'true';
|
||||
const allowEdit = !isShare || showEditOnly || isOwner;
|
||||
|
||||
let quantityBadge = '';
|
||||
if (item.quantity && item.quantity > 1) {
|
||||
quantityBadge = `<span class="badge bg-secondary">x${item.quantity}</span>`;
|
||||
@@ -309,8 +312,8 @@ function renderItem(item, isShare = window.IS_SHARE, showEditOnly = false) {
|
||||
|
||||
let rightButtons = '';
|
||||
|
||||
// ✏️ i 🗑️ — tylko jeśli nie jesteśmy w trybie /share lub jesteśmy w 15s (tymczasowo)
|
||||
if (!isShare || showEditOnly) {
|
||||
// ✏️ i 🗑️ — tylko jeśli nie jesteśmy w trybie /share lub jesteśmy w 15s (tymczasowo) lub jesteśmy właścicielem
|
||||
if (allowEdit) {
|
||||
rightButtons += `
|
||||
<button type="button" class="btn btn-outline-light"
|
||||
onclick="editItem(${item.id}, '${item.name.replace(/'/g, "\\'")}', ${item.quantity || 1})">
|
||||
@@ -332,7 +335,8 @@ function renderItem(item, isShare = window.IS_SHARE, showEditOnly = false) {
|
||||
}
|
||||
|
||||
// ⚠️ tylko jeśli NIE jest oznaczony jako niekupiony i nie jesteśmy w 15s
|
||||
if (!item.not_purchased && !showEditOnly) {
|
||||
if (!item.not_purchased && (isOwner || (isShare && !showEditOnly))) {
|
||||
|
||||
rightButtons += `
|
||||
<button type="button" class="btn btn-outline-light"
|
||||
onclick="markNotPurchasedModal(event, ${item.id})">
|
||||
@@ -341,7 +345,8 @@ function renderItem(item, isShare = window.IS_SHARE, showEditOnly = false) {
|
||||
}
|
||||
|
||||
// 📝 tylko jeśli jesteśmy w /share i nie jesteśmy w 15s
|
||||
if (isShare && !showEditOnly) {
|
||||
if (isShare && !showEditOnly && !isOwner) {
|
||||
|
||||
rightButtons += `
|
||||
<button type="button" class="btn btn-outline-light"
|
||||
onclick="openNoteModal(event, ${item.id})">
|
||||
@@ -349,7 +354,6 @@ function renderItem(item, isShare = window.IS_SHARE, showEditOnly = false) {
|
||||
</button>`;
|
||||
}
|
||||
|
||||
|
||||
li.innerHTML = `${left}<div class="btn-group btn-group-sm" role="group">${rightButtons}</div>`;
|
||||
|
||||
if (item.added_by && item.owner_id && item.added_by_id && item.added_by_id !== item.owner_id) {
|
||||
|
@@ -205,43 +205,39 @@ function setupList(listId, username) {
|
||||
});
|
||||
|
||||
socket.on('note_updated', data => {
|
||||
const itemEl = document.getElementById(`item-${data.item_id}`);
|
||||
if (itemEl) {
|
||||
let noteEl = itemEl.querySelector('small');
|
||||
if (noteEl) {
|
||||
//noteEl.innerHTML = `[ Notatka: <b>${data.note}</b> ]`;
|
||||
noteEl.innerHTML = `[ <b>${data.note}</b> ]`;
|
||||
} else {
|
||||
const newNote = document.createElement('small');
|
||||
newNote.className = 'text-danger ms-4';
|
||||
//newNote.innerHTML = `[ Notatka: <b>${data.note}</b> ]`;
|
||||
newNote.innerHTML = `[ <b>${data.note}</b> ]`;
|
||||
const idx = window.currentItems.findIndex(i => i.id === data.item_id);
|
||||
if (idx !== -1) {
|
||||
window.currentItems[idx].note = data.note;
|
||||
|
||||
const flexColumn = itemEl.querySelector('.d-flex.flex-column');
|
||||
if (flexColumn) {
|
||||
flexColumn.appendChild(newNote);
|
||||
} else {
|
||||
itemEl.appendChild(newNote);
|
||||
}
|
||||
const newItem = renderItem(window.currentItems[idx], true);
|
||||
const oldItem = document.getElementById(`item-${data.item_id}`);
|
||||
if (oldItem && newItem) {
|
||||
oldItem.replaceWith(newItem);
|
||||
}
|
||||
}
|
||||
|
||||
showToast('Notatka dodana/zaktualizowana', 'success');
|
||||
});
|
||||
|
||||
socket.on('item_edited', data => {
|
||||
const nameSpan = document.getElementById(`name-${data.item_id}`);
|
||||
if (nameSpan) {
|
||||
let quantityBadge = '';
|
||||
if (data.new_quantity && data.new_quantity > 1) {
|
||||
quantityBadge = ` <span class="badge bg-secondary">x${data.new_quantity}</span>`;
|
||||
}
|
||||
nameSpan.innerHTML = `${data.new_name}${quantityBadge}`;
|
||||
}
|
||||
showToast(`Zaktualizowano produkt: ${data.new_name} (x${data.new_quantity})`, 'success');
|
||||
});
|
||||
|
||||
updateProgressBar();
|
||||
toggleEmptyPlaceholder();
|
||||
socket.on('item_edited', data => {
|
||||
const idx = window.currentItems.findIndex(i => i.id === data.item_id);
|
||||
if (idx !== -1) {
|
||||
window.currentItems[idx].name = data.new_name;
|
||||
window.currentItems[idx].quantity = data.new_quantity;
|
||||
|
||||
const newItem = renderItem(window.currentItems[idx], true);
|
||||
const oldItem = document.getElementById(`item-${data.item_id}`);
|
||||
if (oldItem && newItem) {
|
||||
oldItem.replaceWith(newItem);
|
||||
}
|
||||
}
|
||||
|
||||
showToast(`Zaktualizowano produkt: ${data.new_name} (x${data.new_quantity})`, 'success');
|
||||
|
||||
updateProgressBar();
|
||||
toggleEmptyPlaceholder();
|
||||
});
|
||||
|
||||
// --- WAŻNE: zapisz dane do reconnect ---
|
||||
window.LIST_ID = listId;
|
||||
|
96
static/js/receipt_crop_logic.js
Normal file
96
static/js/receipt_crop_logic.js
Normal file
@@ -0,0 +1,96 @@
|
||||
(function () {
|
||||
function initCropper(imgEl) {
|
||||
return new Cropper(imgEl, {
|
||||
viewMode: 1,
|
||||
autoCropArea: 1,
|
||||
responsive: true,
|
||||
background: false,
|
||||
zoomable: true,
|
||||
movable: true,
|
||||
dragMode: 'move',
|
||||
minContainerHeight: 400,
|
||||
minContainerWidth: 400,
|
||||
});
|
||||
}
|
||||
|
||||
function cleanUpCropper(imgEl, cropperInstance) {
|
||||
if (cropperInstance) {
|
||||
cropperInstance.destroy();
|
||||
}
|
||||
if (imgEl) imgEl.src = "";
|
||||
}
|
||||
|
||||
function handleCrop(endpoint, receiptId, cropper, spinner) {
|
||||
const cropData = cropper.getData();
|
||||
const imageData = cropper.getImageData();
|
||||
|
||||
const scaleX = imageData.naturalWidth / imageData.width;
|
||||
const scaleY = imageData.naturalHeight / imageData.height;
|
||||
|
||||
const width = cropData.width * scaleX;
|
||||
const height = cropData.height * scaleY;
|
||||
|
||||
if (width < 1 || height < 1) {
|
||||
spinner.classList.add("d-none");
|
||||
showToast("Obszar przycięcia jest zbyt mały lub pusty", "danger");
|
||||
return;
|
||||
}
|
||||
|
||||
const maxDim = 2000;
|
||||
const scale = Math.min(1, maxDim / Math.max(width, height));
|
||||
|
||||
const finalWidth = Math.round(width * scale);
|
||||
const finalHeight = Math.round(height * scale);
|
||||
|
||||
const croppedCanvas = cropper.getCroppedCanvas({
|
||||
width: finalWidth,
|
||||
height: finalHeight,
|
||||
imageSmoothingEnabled: true,
|
||||
imageSmoothingQuality: 'high',
|
||||
});
|
||||
|
||||
if (!croppedCanvas) {
|
||||
spinner.classList.add("d-none");
|
||||
showToast("Nie można uzyskać obrazu przycięcia", "danger");
|
||||
return;
|
||||
}
|
||||
|
||||
croppedCanvas.toBlob(function (blob) {
|
||||
if (!blob) {
|
||||
spinner.classList.add("d-none");
|
||||
showToast("Nie udało się zapisać obrazu", "danger");
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("receipt_id", receiptId);
|
||||
formData.append("cropped_image", blob);
|
||||
|
||||
fetch(endpoint, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
spinner.classList.add("d-none");
|
||||
if (data.success) {
|
||||
showToast("Zapisano przycięty paragon", "success");
|
||||
setTimeout(() => location.reload(), 1500);
|
||||
} else {
|
||||
showToast("Błąd: " + (data.error || "Nieznany"), "danger");
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
spinner.classList.add("d-none");
|
||||
showToast("Błąd sieci", "danger");
|
||||
console.error(err);
|
||||
});
|
||||
}, "image/webp", 1.0);
|
||||
}
|
||||
|
||||
window.cropUtils = {
|
||||
initCropper,
|
||||
cleanUpCropper,
|
||||
handleCrop,
|
||||
};
|
||||
})();
|
@@ -16,3 +16,24 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
localStorage.setItem("receiptSectionOpen", "false");
|
||||
});
|
||||
});
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
const btn = document.getElementById("toggleReceiptBtn");
|
||||
const target = document.querySelector(btn.getAttribute("data-bs-target"));
|
||||
|
||||
function updateUI() {
|
||||
const isShown = target.classList.contains("show");
|
||||
btn.innerHTML = isShown
|
||||
? "📄 Ukryj sekcję paragonów"
|
||||
: "📄 Pokaż sekcję paragonów";
|
||||
|
||||
btn.classList.toggle("active", isShown);
|
||||
btn.classList.toggle("btn-outline-light", !isShown);
|
||||
btn.classList.toggle("btn-secondary", isShown);
|
||||
}
|
||||
|
||||
target.addEventListener("shown.bs.collapse", updateUI);
|
||||
target.addEventListener("hidden.bs.collapse", updateUI);
|
||||
|
||||
updateUI();
|
||||
});
|
39
static/js/user_receipt_crop.js
Normal file
39
static/js/user_receipt_crop.js
Normal file
@@ -0,0 +1,39 @@
|
||||
(function () {
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
const cropModal = document.getElementById("userCropModal");
|
||||
const cropImage = document.getElementById("userCropImage");
|
||||
const spinner = document.getElementById("userCropLoading");
|
||||
const saveButton = document.getElementById("userSaveCrop");
|
||||
|
||||
if (!cropModal || !cropImage || !spinner || !saveButton) return;
|
||||
|
||||
let cropper;
|
||||
let currentReceiptId;
|
||||
const currentEndpoint = "/user_crop_receipt";
|
||||
|
||||
cropModal.addEventListener("shown.bs.modal", function (event) {
|
||||
const button = event.relatedTarget;
|
||||
const imgSrc = button.getAttribute("data-img-src");
|
||||
currentReceiptId = button.getAttribute("data-receipt-id");
|
||||
cropImage.src = imgSrc;
|
||||
|
||||
document.querySelectorAll('.cropper-container').forEach(e => e.remove());
|
||||
|
||||
if (cropper) cropper.destroy();
|
||||
cropImage.onload = () => {
|
||||
cropper = cropUtils.initCropper(cropImage);
|
||||
};
|
||||
});
|
||||
|
||||
cropModal.addEventListener("hidden.bs.modal", function () {
|
||||
cropUtils.cleanUpCropper(cropImage, cropper);
|
||||
cropper = null;
|
||||
});
|
||||
|
||||
saveButton.addEventListener("click", function () {
|
||||
if (!cropper) return;
|
||||
spinner.classList.remove("d-none");
|
||||
cropUtils.handleCrop(currentEndpoint, currentReceiptId, cropper, spinner);
|
||||
});
|
||||
});
|
||||
})();
|
1
static/lib/css/sort_table.min.css
vendored
Normal file
1
static/lib/css/sort_table.min.css
vendored
Normal file
@@ -0,0 +1 @@
|
||||
.sortable thead th:not(.no-sort){cursor:pointer}.sortable thead th:not(.no-sort)::after,.sortable thead th:not(.no-sort)::before{transition:color .1s ease-in-out;font-size:1.2em;color:rgba(0,0,0,0)}.sortable thead th:not(.no-sort)::after{margin-left:3px;content:"▸"}.sortable thead th:not(.no-sort):hover::after{color:inherit}.sortable thead th:not(.no-sort)[aria-sort=descending]::after{color:inherit;content:"▾"}.sortable thead th:not(.no-sort)[aria-sort=ascending]::after{color:inherit;content:"▴"}.sortable thead th:not(.no-sort).indicator-left::after{content:""}.sortable thead th:not(.no-sort).indicator-left::before{margin-right:3px;content:"▸"}.sortable thead th:not(.no-sort).indicator-left:hover::before{color:inherit}.sortable thead th:not(.no-sort).indicator-left[aria-sort=descending]::before{color:inherit;content:"▾"}.sortable thead th:not(.no-sort).indicator-left[aria-sort=ascending]::before{color:inherit;content:"▴"}
|
4
static/lib/js/sort_table.min.js
vendored
Normal file
4
static/lib/js/sort_table.min.js
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
document.addEventListener("click",function(d){try{var A=d.shiftKey||d.altKey,f=function k(a,l){return a.nodeName===l?a:k(a.parentNode,l)}(d.target,"TH"),v=f.parentNode,w=v.parentNode,g=w.parentNode;if("THEAD"===w.nodeName&&g.classList.contains("sortable")&&!f.classList.contains("no-sort")){var h=v.cells;for(d=0;d<h.length;d++)h[d]!==f&&h[d].removeAttribute("aria-sort");h="descending";("descending"===f.getAttribute("aria-sort")||g.classList.contains("asc")&&"ascending"!==f.getAttribute("aria-sort"))&&
|
||||
(h="ascending");f.setAttribute("aria-sort",h);g.dataset.timer&&clearTimeout(+g.dataset.timer);g.dataset.timer=setTimeout(function(){(function(a,l){function k(b){if(b){if(l&&b.dataset.sortAlt)return b.dataset.sortAlt;if(b.dataset.sort)return b.dataset.sort;if(b.textContent)return b.textContent}return""}a.dispatchEvent(new Event("sort-start",{bubbles:!0}));for(var p=a.tHead.querySelector("th[aria-sort]"),q=a.tHead.children[0],B="ascending"===p.getAttribute("aria-sort"),C=a.classList.contains("n-last"),
|
||||
y=function(b,m,c){var e=k(m.cells[c]),n=k(b.cells[c]);if(C){if(""===e&&""!==n)return-1;if(""===n&&""!==e)return 1}var x=+e-+n;e=isNaN(x)?e.localeCompare(n):x;return 0===e&&q.cells[c]&&q.cells[c].hasAttribute("data-sort-tbr")?y(b,m,+q.cells[c].dataset.sortTbr):B?-e:e},r=0;r<a.tBodies.length;r++){var t=a.tBodies[r],z=[].slice.call(t.rows,0);z.sort(function(b,m){var c;return y(b,m,+(null!==(c=p.dataset.sortCol)&&void 0!==c?c:p.cellIndex))});var u=t.cloneNode();u.append.apply(u,z);a.replaceChild(u,t)}a.dispatchEvent(new Event("sort-end",
|
||||
{bubbles:!0}))})(g,A)},1).toString()}}catch{}});
|
@@ -80,7 +80,7 @@
|
||||
<h3 class="mt-4">📄 Wszystkie listy zakupowe</h3>
|
||||
<form method="post" action="{{ url_for('delete_selected_lists') }}">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-dark table-striped align-middle">
|
||||
<table class="table table-dark table-striped align-middle sortable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><input type="checkbox" id="select-all"></th>
|
||||
@@ -195,6 +195,7 @@
|
||||
});
|
||||
</script>
|
||||
<script src="{{ url_for('static_bp.serve_js', filename='expenses.js') }}"></script>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
<div class="info-bar-fixed">
|
||||
|
@@ -65,6 +65,21 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Aktualna data utworzenia listy</label>
|
||||
<p class="form-control-plaintext text-white">
|
||||
{{ list.created_at.strftime('%Y-%m-%d') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label for="created_month" class="form-label">Przenieś listę do miesiąca</label>
|
||||
<input type="month" id="created_month" name="created_month"
|
||||
class="form-control bg-dark text-white border-secondary rounded">
|
||||
</div>
|
||||
</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
|
||||
|
@@ -5,8 +5,8 @@
|
||||
<div class="d-flex justify-content-between align-items-center flex-wrap mb-4">
|
||||
<h2 class="mb-2">📸 Wszystkie paragony</h2>
|
||||
<div>
|
||||
<a href="{{ url_for('recalculate_filesizes') }}" class="btn btn-sm btn-outline-primary me-2">
|
||||
🔄 Przelicz rozmiary plików
|
||||
<a href="{{ url_for('recalculate_filesizes_all') }}" class="btn btn-outline-primary me-2">
|
||||
Przelicz rozmiary plików
|
||||
</a>
|
||||
<a href="/admin" class="btn btn-outline-secondary">← Powrót do panelu</a>
|
||||
</div>
|
||||
@@ -36,16 +36,18 @@
|
||||
<a href="{{ url_for('rotate_receipt', receipt_id=r.id) }}"
|
||||
class="btn btn-sm btn-outline-warning w-100 mb-2">🔄 Obróć o 90°</a>
|
||||
<a href="#" class="btn btn-sm btn-outline-secondary w-100 mb-2" data-bs-toggle="modal"
|
||||
data-bs-target="#cropModal" data-img-src="{{ url_for('uploaded_file', filename=r.filename) }}"
|
||||
data-receipt-id="{{ r.id }}">✂️ Przytnij</a>
|
||||
data-bs-target="#adminCropModal" data-img-src="{{ url_for('uploaded_file', filename=r.filename) }}"
|
||||
data-receipt-id="{{ r.id }}" data-crop-endpoint="{{ url_for('crop_receipt_admin') }}">
|
||||
✂️ Przytnij
|
||||
</a>
|
||||
<a href="{{ url_for('rename_receipt', receipt_id=r.id) }}" class="btn btn-sm btn-outline-info w-100 mb-2">✏️
|
||||
Zmień nazwę</a>
|
||||
{% if not r.file_hash %}
|
||||
<a href="{{ url_for('generate_receipt_hash', receipt_id=r.id) }}"
|
||||
class="btn btn-sm btn-outline-secondary w-100 mb-2">🔐 Generuj hash</a>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('delete_receipt', receipt_id=r.id) }}"
|
||||
class="btn btn-sm btn-outline-danger w-100 mb-2">🗑️
|
||||
<a href="{{ url_for('delete_receipt', receipt_id=r.id) }}" class="btn btn-sm btn-outline-danger w-100 mb-2"
|
||||
onclick="return confirm('Na pewno usunąć plik {{ r.filename }}?');">🗑️
|
||||
Usuń</a>
|
||||
<a href="{{ url_for('edit_list', list_id=r.list_id) }}" class="btn btn-sm btn-outline-light w-100 mb-2">✏️
|
||||
Edytuj listę #{{ r.list_id }}</a>
|
||||
@@ -64,7 +66,33 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="cropModal" tabindex="-1" aria-labelledby="cropModalLabel" aria-hidden="true">
|
||||
{% if orphan_files and request.path.endswith('/all') %}
|
||||
<hr class="my-4">
|
||||
<h4 class="mt-3 mb-2 text-warning">Znalezione nieprzypisane pliki ({{ orphan_files_count }})</h4>
|
||||
<div class="row g-3">
|
||||
{% for f in orphan_files %}
|
||||
<div class="col-6 col-md-4 col-lg-3">
|
||||
<div class="card bg-dark border-warning text-warning h-100">
|
||||
<a href="{{ url_for('uploaded_file', filename=f) }}" class="glightbox" data-gallery="receipts"
|
||||
data-title="{{ f }}">
|
||||
<img src="{{ url_for('uploaded_file', filename=f) }}" class="card-img-top"
|
||||
style="object-fit: cover; height: 200px;">
|
||||
</a>
|
||||
<div class="card-body text-center">
|
||||
<p class="small mb-1 fw-bold">{{ f }}</p>
|
||||
<div class="alert alert-warning small py-1 mb-2">Brak powiązania z listą!</div>
|
||||
<a href="{{ url_for('delete_receipt', filename=f) }}" class="btn btn-sm btn-outline-danger w-100 mb-2"
|
||||
onclick="return confirm('Na pewno usunąć WYŁĄCZNIE plik {{ f }} z dysku?');">
|
||||
🗑 Usuń plik z serwera
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="modal fade" id="adminCropModal" tabindex="-1" aria-labelledby="userCropModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-xl modal-dialog-centered">
|
||||
<div class="modal-content bg-dark text-white">
|
||||
<div class="modal-header">
|
||||
@@ -72,14 +100,12 @@
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div style="position: relative; width: 100%; height: 75vh;">
|
||||
<img id="cropImage" style="max-width: 100%; max-height: 100%; display: block; margin: auto;">
|
||||
<img id="adminCropImage" style="max-width: 100%; max-height: 100%; display: block; margin: auto;">
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" data-bs-dismiss="modal">Anuluj</button>
|
||||
<button class="btn btn-success" id="saveCrop">Zapisz</button>
|
||||
<div id="cropLoading" class="position-absolute top-50 start-50 translate-middle text-center d-none"
|
||||
style="z-index: 1055;">
|
||||
<button class="btn btn-success" id="adminSaveCrop">Zapisz</button>
|
||||
<div id="adminCropLoading" class="position-absolute top-50 start-50 translate-middle text-center d-none">
|
||||
<div class="spinner-border text-light" role="status"></div>
|
||||
<div class="mt-2 text-light">⏳ Pracuję...</div>
|
||||
</div>
|
||||
@@ -88,8 +114,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
{% block scripts %}
|
||||
<script src="{{ url_for('static_bp.serve_js', filename='receipt_crop.js') }}"></script>
|
||||
<script src="{{ url_for('static_bp.serve_js', filename='admin_receipt_crop.js') }}"></script>
|
||||
<script src="{{ url_for('static_bp.serve_js', filename='receipt_crop_logic.js') }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% endblock %}
|
@@ -9,6 +9,7 @@
|
||||
{% if not is_blocked %}
|
||||
<link href="{{ url_for('static_bp.serve_css', filename='style.css') }}" rel="stylesheet">
|
||||
<link href="{{ url_for('static_bp.serve_css_lib', filename='glightbox.min.css') }}" rel="stylesheet">
|
||||
<link href="{{ url_for('static_bp.serve_css_lib', filename='sort_table.min.css') }}" rel="stylesheet">
|
||||
{% endif %}
|
||||
<link href="{{ url_for('static_bp.serve_css_lib', filename='bootstrap.min.css') }}" rel="stylesheet">
|
||||
{% if '/admin/' in request.path %}
|
||||
@@ -51,7 +52,6 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@@ -87,6 +87,7 @@
|
||||
{% if request.endpoint != 'system_auth' %}
|
||||
<script src="{{ url_for('static_bp.serve_js_lib', filename='glightbox.min.js') }}"></script>
|
||||
<script src="{{ url_for('static_bp.serve_js_lib', filename='socket.io.min.js') }}"></script>
|
||||
<script src="{{ url_for('static_bp.serve_js_lib', filename='sort_table.min.js') }}"></script>
|
||||
<script src="{{ url_for('static_bp.serve_js', filename='functions.js') }}"></script>
|
||||
<script src="{{ url_for('static_bp.serve_js', filename='live.js') }}"></script>
|
||||
<script src="{{ url_for('static_bp.serve_js', filename='sockets.js') }}"></script>
|
||||
@@ -98,7 +99,7 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
{% if '/admin/' in request.path %}
|
||||
{% if '/admin/receipts' in request.path or '/edit_my_list' in request.path %}
|
||||
<script src="{{ url_for('static_bp.serve_js_lib', filename='cropper.min.js') }}"></script>
|
||||
{% endif %}
|
||||
|
||||
|
@@ -37,6 +37,21 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Aktualna data utworzenia:</label>
|
||||
<p class="form-control-plaintext text-white">
|
||||
{{ list.created_at.strftime('%Y-%m-%d') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label for="move_to_month" class="form-label">Przenieś listę do miesiąca</label>
|
||||
<input type="month" id="move_to_month" name="move_to_month"
|
||||
class="form-control bg-dark text-white border-secondary rounded">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-check mb-3">
|
||||
<input class="form-check-input" type="checkbox" name="is_archived" id="is_archived" {% if list.is_archived
|
||||
%}checked{% endif %}>
|
||||
@@ -76,6 +91,11 @@
|
||||
<a href="{{ url_for('rotate_receipt_user', receipt_id=r.id) }}"
|
||||
class="btn btn-sm btn-outline-warning w-100 mb-2">🔄 Obróć o 90°</a>
|
||||
|
||||
<a href="#" class="btn btn-sm btn-outline-secondary w-100 mb-2 disabled" data-bs-toggle="modal"
|
||||
data-bs-target="#userCropModal" data-img-src="{{ url_for('uploaded_file', filename=r.filename) }}"
|
||||
data-receipt-id="{{ r.id }}" data-crop-endpoint="{{ url_for('crop_receipt_user') }}">
|
||||
✂️ Przytnij
|
||||
</a>
|
||||
<a href="{{ url_for('delete_receipt_user', receipt_id=r.id) }}" class="btn btn-sm btn-outline-danger w-100"
|
||||
onclick="return confirm('Na pewno usunąć ten paragon?')">🗑️ Usuń</a>
|
||||
</div>
|
||||
@@ -116,14 +136,35 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="delete-form" method="post" action="{{ url_for('delete_user_list', list_id=list.id) }}"></form>
|
||||
|
||||
|
||||
<!-- Hidden delete form -->
|
||||
<form id="delete-form" method="post" action="{{ url_for('delete_user_list', list_id=list.id) }}"></form>
|
||||
|
||||
<div class="modal fade" id="userCropModal" tabindex="-1" aria-labelledby="userCropModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-xl modal-dialog-centered">
|
||||
<div class="modal-content bg-dark text-white">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">✂️ Przycinanie paragonu</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div style="position: relative; width: 100%; height: 75vh;">
|
||||
<img id="userCropImage" style="max-width: 100%; max-height: 100%; display: block; margin: auto;">
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" data-bs-dismiss="modal">Anuluj</button>
|
||||
<button class="btn btn-success" id="userSaveCrop">Zapisz</button>
|
||||
<div id="userCropLoading" class="position-absolute top-50 start-50 translate-middle text-center d-none">
|
||||
<div class="spinner-border text-light" role="status"></div>
|
||||
<div class="mt-2 text-light">⏳ Pracuję...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="{{ url_for('static_bp.serve_js', filename='confirm_delete.js') }}"></script>
|
||||
<script src="{{ url_for('static_bp.serve_js', filename='user_receipt_crop.js') }}"></script>
|
||||
<script src="{{ url_for('static_bp.serve_js', filename='receipt_crop_logic.js') }}"></script>
|
||||
{% endblock %}
|
@@ -55,7 +55,7 @@
|
||||
📊 Postęp listy —
|
||||
<span id="purchased-count">{{ purchased_count }}</span>/
|
||||
<span id="total-count">{{ total_count }}</span> kupionych
|
||||
(<span id="percent-value">{{ percent|round(0) }}</span>%)
|
||||
(<span id="percent-value">{{ percent|int }}</span>%)
|
||||
</h5>
|
||||
|
||||
<div class="progress progress-dark position-relative">
|
||||
@@ -108,13 +108,26 @@
|
||||
{% endif %}
|
||||
</span>
|
||||
|
||||
{% if item.note %}
|
||||
<small class="text-danger ms-4">[ <b>{{ item.note }}</b> ]</small>
|
||||
{% endif %}
|
||||
<div class="info-line ms-4 small d-flex flex-wrap gap-2" id="info-{{ item.id }}">
|
||||
{% set info_parts = [] %}
|
||||
{% if item.note %}
|
||||
{% set _ = info_parts.append('<span class="text-danger">[ <b>' ~ item.note ~ '</b> ]</span>') %}
|
||||
{% endif %}
|
||||
{% if item.not_purchased_reason %}
|
||||
{% set _ = info_parts.append('<span class="text-dark">[ <b>Powód: ' ~ item.not_purchased_reason ~ '</b>
|
||||
]</span>') %}
|
||||
{% endif %}
|
||||
{% if item.added_by_display %}
|
||||
{% set _ = info_parts.append('<span class="text-info">[ Dodał/a: <b>' ~ item.added_by_display ~ '</b> ]</span>')
|
||||
%}
|
||||
{% endif %}
|
||||
|
||||
{% if item.not_purchased_reason %}
|
||||
<small class="text-dark ms-4">[ <b>Powód: {{ item.not_purchased_reason }}</b> ]</small>
|
||||
{% endif %}
|
||||
{% if info_parts %}
|
||||
<div class="info-line ms-4 small d-flex flex-wrap gap-2" id="info-{{ item.id }}">
|
||||
{{ info_parts | join(' ') | safe }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
@@ -215,6 +228,8 @@
|
||||
const isShare = document.getElementById('items').dataset.isShare === 'true';
|
||||
window.IS_SHARE = isShare;
|
||||
window.LIST_ID = {{ list.id }};
|
||||
window.IS_OWNER = {{ 'true' if is_owner else 'false' }};
|
||||
|
||||
</script>
|
||||
|
||||
<script src="{{ url_for('static_bp.serve_js', filename='mass_add.js') }}"></script>
|
||||
|
@@ -43,12 +43,26 @@
|
||||
{% endif %}
|
||||
</span>
|
||||
|
||||
{% if item.note %}
|
||||
<small class="text-danger ms-4">[ <b>{{ item.note }}</b> ]</small>
|
||||
{% endif %}
|
||||
{% if item.not_purchased_reason %}
|
||||
<small class="text-dark ms-4">[ <b>Powód: {{ item.not_purchased_reason }}</b> ]</small>
|
||||
{% endif %}
|
||||
<div class="info-line ms-4 small d-flex flex-wrap gap-2" id="info-{{ item.id }}">
|
||||
{% set info_parts = [] %}
|
||||
{% if item.note %}
|
||||
{% set _ = info_parts.append('<span class="text-danger">[ <b>' ~ item.note ~ '</b> ]</span>') %}
|
||||
{% endif %}
|
||||
{% if item.not_purchased_reason %}
|
||||
{% set _ = info_parts.append('<span class="text-dark">[ <b>Powód: ' ~ item.not_purchased_reason ~ '</b>
|
||||
]</span>') %}
|
||||
{% endif %}
|
||||
{% if item.added_by_display %}
|
||||
{% set _ = info_parts.append('<span class="text-info">[ Dodał/a: <b>' ~ item.added_by_display ~ '</b> ]</span>')
|
||||
%}
|
||||
{% endif %}
|
||||
|
||||
{% if info_parts %}
|
||||
<div class="info-line ms-4 small d-flex flex-wrap gap-2" id="info-{{ item.id }}">
|
||||
{{ info_parts | join(' ') | safe }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
@@ -100,14 +114,13 @@
|
||||
{% endif %}
|
||||
<p id="total-expense2"><b>💸 Łącznie wydano:</b> {{ '%.2f'|format(total_expense) }} PLN</p>
|
||||
|
||||
<button class="btn btn-outline-light mb-3" type="button" data-bs-toggle="collapse" data-bs-target="#receiptSection"
|
||||
aria-expanded="false" aria-controls="receiptSection">
|
||||
<button id="toggleReceiptBtn" class="btn btn-outline-light mb-3 w-100 w-md-auto d-block mx-auto" type="button"
|
||||
data-bs-toggle="collapse" data-bs-target="#receiptSection" aria-expanded="false" aria-controls="receiptSection">
|
||||
📄 Pokaż sekcję paragonów
|
||||
</button>
|
||||
<div class="collapse" id="receiptSection">
|
||||
<div class="collapse px-2 px-md-4" id="receiptSection">
|
||||
{% set receipt_pattern = 'list_' ~ list.id %}
|
||||
|
||||
<hr>
|
||||
<div class="mt-3 p-3 border border-secondary rounded bg-dark text-white {% if not receipt_files %}d-none{% endif %}"
|
||||
id="receiptAnalysisBlock">
|
||||
<h5>🧠 Analiza paragonów (OCR)</h5>
|
||||
|
@@ -32,7 +32,7 @@
|
||||
|
||||
<div class="tab-content" id="expenseTabsContent">
|
||||
|
||||
<!-- 📚 LISTY -->
|
||||
<!-- LISTY -->
|
||||
<div class="tab-pane fade show active" id="listsTab" role="tabpanel">
|
||||
<div class="card bg-dark text-white mb-4">
|
||||
<div class="card-body">
|
||||
@@ -66,7 +66,7 @@
|
||||
</button>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-dark table-striped align-middle">
|
||||
<table class="table table-dark table-striped align-middle sortable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
@@ -104,7 +104,7 @@
|
||||
</div>
|
||||
|
||||
|
||||
<!-- 📊 WYKRES -->
|
||||
<!-- WYKRES -->
|
||||
<div class="tab-pane fade" id="chartTab" role="tabpanel">
|
||||
<div class="card bg-dark text-white mb-4">
|
||||
<div class="card-body">
|
||||
|
Reference in New Issue
Block a user