duzo zmian i funkcji
This commit is contained in:
@ -10,3 +10,5 @@ SYSTEM_PASSWORD=admin
|
||||
# Domyślny admin (login i hasło)
|
||||
DEFAULT_ADMIN_USERNAME=admin
|
||||
DEFAULT_ADMIN_PASSWORD=admin123
|
||||
|
||||
UPLOAD_FOLDER=uploads
|
3
.gitignore
vendored
3
.gitignore
vendored
@ -3,4 +3,5 @@ venv
|
||||
env
|
||||
*.db
|
||||
__pycache__
|
||||
instance
|
||||
instance/
|
||||
uploads/
|
@ -2,4 +2,8 @@
|
||||
CREATE TABLE IF NOT EXISTS suggested_product (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT UNIQUE NOT NULL
|
||||
);
|
||||
);
|
||||
|
||||
# NOTATKI
|
||||
ALTER TABLE item
|
||||
ADD COLUMN note TEXT;
|
179
app.py
179
app.py
@ -3,17 +3,27 @@ import secrets
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from flask import Flask, render_template, redirect, url_for, request, flash, Blueprint, send_from_directory
|
||||
from markupsafe import Markup
|
||||
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_login import LoginManager, UserMixin, login_user, login_required, logout_user, current_user
|
||||
from flask_socketio import SocketIO, emit, join_room
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
from config import Config
|
||||
from PIL import Image
|
||||
from werkzeug.utils import secure_filename
|
||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config.from_object(Config)
|
||||
app.wsgi_app = ProxyFix(app.wsgi_app, x_proto=1, x_host=1)
|
||||
SYSTEM_PASSWORD = app.config.get('SYSTEM_PASSWORD', 'changeme')
|
||||
DEFAULT_ADMIN_USERNAME = app.config.get('DEFAULT_ADMIN_USERNAME', 'admin')
|
||||
DEFAULT_ADMIN_PASSWORD = app.config.get('DEFAULT_ADMIN_PASSWORD', 'admin123')
|
||||
UPLOAD_FOLDER = app.config.get('UPLOAD_FOLDER', 'uploads')
|
||||
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'webp'}
|
||||
|
||||
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
|
||||
|
||||
db = SQLAlchemy(app)
|
||||
socketio = SocketIO(app)
|
||||
@ -22,19 +32,6 @@ login_manager.login_view = 'login'
|
||||
|
||||
static_bp = Blueprint('static_bp', __name__)
|
||||
|
||||
@static_bp.route('/static/js/live.js')
|
||||
def serve_live_js():
|
||||
response = send_from_directory('static/js', 'live.js')
|
||||
response.cache_control.no_cache = True
|
||||
response.cache_control.no_store = True
|
||||
response.cache_control.must_revalidate = True
|
||||
response.expires = 0
|
||||
response.pragma = 'no-cache'
|
||||
return response
|
||||
|
||||
app.register_blueprint(static_bp)
|
||||
|
||||
|
||||
class User(UserMixin, db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
username = db.Column(db.String(150), unique=True, nullable=False)
|
||||
@ -59,11 +56,27 @@ class Item(db.Model):
|
||||
added_by = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True)
|
||||
purchased = db.Column(db.Boolean, default=False)
|
||||
purchased_at = db.Column(db.DateTime, nullable=True)
|
||||
note = db.Column(db.Text, nullable=True)
|
||||
|
||||
class SuggestedProduct(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(150), unique=True, nullable=False)
|
||||
|
||||
@static_bp.route('/static/js/live.js')
|
||||
def serve_live_js():
|
||||
response = send_from_directory('static/js', 'live.js')
|
||||
response.cache_control.no_cache = True
|
||||
response.cache_control.no_store = True
|
||||
response.cache_control.must_revalidate = True
|
||||
response.expires = 0
|
||||
response.pragma = 'no-cache'
|
||||
return response
|
||||
|
||||
app.register_blueprint(static_bp)
|
||||
|
||||
def allowed_file(filename):
|
||||
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
|
||||
|
||||
@login_manager.user_loader
|
||||
def load_user(user_id):
|
||||
return User.query.get(int(user_id))
|
||||
@ -78,12 +91,39 @@ def require_system_password():
|
||||
and request.endpoint != 'system_auth' \
|
||||
and not request.endpoint.startswith('static') \
|
||||
and not request.endpoint.startswith('login'):
|
||||
return redirect(url_for('system_auth', next=request.url))
|
||||
# Jeśli wchodzi na '/', nie dodawaj next
|
||||
if request.path == '/':
|
||||
return redirect(url_for('system_auth'))
|
||||
else:
|
||||
# W innym przypadku poprawiamy URL jak wcześniej
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
parsed = urlparse(request.url)
|
||||
fixed_url = urlunparse(parsed._replace(netloc=request.host))
|
||||
return redirect(url_for('system_auth', next=fixed_url))
|
||||
|
||||
@app.template_filter('filemtime')
|
||||
def file_mtime_filter(path):
|
||||
try:
|
||||
t = os.path.getmtime(path)
|
||||
return datetime.fromtimestamp(t)
|
||||
except Exception:
|
||||
return datetime.utcnow()
|
||||
|
||||
@app.template_filter('filesizeformat')
|
||||
def filesizeformat_filter(path):
|
||||
try:
|
||||
size = os.path.getsize(path)
|
||||
# Jeśli chcesz dokładniejszy format, np. KB, MB
|
||||
for unit in ['B', 'KB', 'MB', 'GB']:
|
||||
if size < 1024.0:
|
||||
return f"{size:.1f} {unit}"
|
||||
size /= 1024.0
|
||||
return f"{size:.1f} TB"
|
||||
except Exception:
|
||||
return "N/A"
|
||||
|
||||
@app.route('/system-auth', methods=['GET', 'POST'])
|
||||
def system_auth():
|
||||
DEFAULT_ADMIN_USERNAME = app.config.get('DEFAULT_ADMIN_USERNAME', 'admin')
|
||||
DEFAULT_ADMIN_PASSWORD = app.config.get('DEFAULT_ADMIN_PASSWORD', 'admin123')
|
||||
|
||||
next_page = request.args.get('next') or url_for('index_guest')
|
||||
|
||||
@ -102,7 +142,7 @@ def system_auth():
|
||||
resp = redirect(next_page)
|
||||
resp.set_cookie('authorized', 'true')
|
||||
return resp
|
||||
flash('Nieprawidłowe hasło do systemu')
|
||||
flash('Nieprawidłowe hasło do systemu','danger')
|
||||
return render_template('system_auth.html')
|
||||
|
||||
@app.route('/')
|
||||
@ -154,19 +194,38 @@ def create_list():
|
||||
def view_list(list_id):
|
||||
shopping_list = ShoppingList.query.get_or_404(list_id)
|
||||
items = Item.query.filter_by(list_id=list_id).all()
|
||||
return render_template('list.html', list=shopping_list, items=items)
|
||||
|
||||
receipt_pattern = f"list_{list_id}"
|
||||
all_files = os.listdir(app.config['UPLOAD_FOLDER'])
|
||||
|
||||
receipt_files = [f for f in all_files if receipt_pattern in f]
|
||||
|
||||
return render_template('list.html', list=shopping_list, items=items, receipt_files=receipt_files)
|
||||
|
||||
@app.route('/share/<token>')
|
||||
def share_list(token):
|
||||
shopping_list = ShoppingList.query.filter_by(share_token=token).first_or_404()
|
||||
items = Item.query.filter_by(list_id=shopping_list.id).all()
|
||||
return render_template('list_guest.html', list=shopping_list, items=items)
|
||||
|
||||
receipt_pattern = f"list_{shopping_list.id}"
|
||||
all_files = os.listdir(app.config['UPLOAD_FOLDER'])
|
||||
receipt_files = [f for f in all_files if receipt_pattern in f]
|
||||
|
||||
return render_template('list_guest.html', list=shopping_list, items=items, receipt_files=receipt_files)
|
||||
|
||||
|
||||
@app.route('/guest-list/<int:list_id>')
|
||||
def guest_list(list_id):
|
||||
shopping_list = ShoppingList.query.get_or_404(list_id)
|
||||
items = Item.query.filter_by(list_id=list_id).all()
|
||||
return render_template('list_guest.html', list=shopping_list, items=items)
|
||||
|
||||
receipt_pattern = f"list_{list_id}"
|
||||
all_files = os.listdir(app.config['UPLOAD_FOLDER'])
|
||||
|
||||
receipt_files = [f for f in all_files if receipt_pattern in f]
|
||||
|
||||
return render_template('list_guest.html', list=shopping_list, items=items, receipt_files=receipt_files)
|
||||
|
||||
|
||||
@app.route('/copy/<int:list_id>')
|
||||
@login_required
|
||||
@ -192,6 +251,47 @@ def suggest_products():
|
||||
suggestions = SuggestedProduct.query.filter(SuggestedProduct.name.ilike(f'%{query}%')).limit(5).all()
|
||||
return {'suggestions': [s.name for s in suggestions]}
|
||||
|
||||
@app.route('/upload_receipt/<int:list_id>', methods=['POST'])
|
||||
def upload_receipt(list_id):
|
||||
if 'receipt' not in request.files:
|
||||
flash('Brak pliku', 'danger')
|
||||
return redirect(request.referrer)
|
||||
|
||||
file = request.files['receipt']
|
||||
|
||||
if file.filename == '':
|
||||
flash('Nie wybrano pliku', 'danger')
|
||||
return redirect(request.referrer)
|
||||
|
||||
if file and allowed_file(file.filename):
|
||||
filename = secure_filename(file.filename)
|
||||
file_path = os.path.join(app.config['UPLOAD_FOLDER'], f"list_{list_id}_{filename}")
|
||||
|
||||
img = Image.open(file)
|
||||
img.thumbnail((800, 800))
|
||||
img.save(file_path)
|
||||
|
||||
flash('Wgrano paragon', 'success')
|
||||
return redirect(request.referrer)
|
||||
|
||||
flash('Niedozwolony format pliku', 'danger')
|
||||
return redirect(request.referrer)
|
||||
|
||||
@app.route('/uploads/<filename>')
|
||||
def uploaded_file(filename):
|
||||
response = send_from_directory(app.config['UPLOAD_FOLDER'], filename)
|
||||
response.headers['Cache-Control'] = 'public, max-age=2592000, immutable'
|
||||
response.headers.pop('Pragma', None)
|
||||
return response
|
||||
|
||||
@app.route('/update-note/<int:item_id>', methods=['POST'])
|
||||
def update_note(item_id):
|
||||
item = Item.query.get_or_404(item_id)
|
||||
note = request.form.get('note')
|
||||
item.note = note
|
||||
db.session.commit()
|
||||
return {'success': True}
|
||||
|
||||
@app.route('/admin')
|
||||
@login_required
|
||||
def admin_panel():
|
||||
@ -278,6 +378,35 @@ def delete_user(user_id):
|
||||
flash('Użytkownik usunięty', 'success')
|
||||
return redirect(url_for('list_users'))
|
||||
|
||||
|
||||
@app.route('/admin/receipts')
|
||||
@login_required
|
||||
def admin_receipts():
|
||||
if not current_user.is_admin:
|
||||
return redirect(url_for('index_guest'))
|
||||
all_files = os.listdir(app.config['UPLOAD_FOLDER'])
|
||||
image_files = [f for f in all_files if allowed_file(f)]
|
||||
return render_template(
|
||||
'admin/receipts.html',
|
||||
image_files=image_files,
|
||||
upload_folder=app.config['UPLOAD_FOLDER']
|
||||
)
|
||||
|
||||
|
||||
@app.route('/admin/delete_receipt/<filename>')
|
||||
@login_required
|
||||
def delete_receipt(filename):
|
||||
if not current_user.is_admin:
|
||||
return redirect(url_for('index_guest'))
|
||||
file_path = os.path.join(app.config['UPLOAD_FOLDER'], filename)
|
||||
if os.path.exists(file_path):
|
||||
os.remove(file_path)
|
||||
flash('Plik usunięty', 'success')
|
||||
else:
|
||||
flash('Plik nie istnieje', 'danger')
|
||||
return redirect(url_for('admin_receipts'))
|
||||
|
||||
|
||||
@socketio.on('delete_item')
|
||||
def handle_delete_item(data):
|
||||
item = Item.query.get(data['item_id'])
|
||||
@ -295,7 +424,6 @@ def handle_edit_item(data):
|
||||
db.session.commit()
|
||||
emit('item_edited', {'item_id': item.id, 'new_name': item.name}, to=str(item.list_id))
|
||||
|
||||
|
||||
@socketio.on('join_list')
|
||||
def handle_join(data):
|
||||
room = str(data['room'])
|
||||
@ -343,6 +471,15 @@ def handle_uncheck_item(data):
|
||||
db.session.commit()
|
||||
emit('item_unchecked', {'item_id': item.id}, to=str(item.list_id))
|
||||
|
||||
@socketio.on('update_note')
|
||||
def handle_update_note(data):
|
||||
item_id = data['item_id']
|
||||
note = data['note']
|
||||
item = Item.query.get(item_id)
|
||||
if item:
|
||||
item.note = note
|
||||
db.session.commit()
|
||||
emit('note_updated', {'item_id': item_id, 'note': note}, to=str(item.list_id))
|
||||
|
||||
@app.cli.command('create_db')
|
||||
def create_db():
|
||||
|
@ -7,3 +7,4 @@ class Config:
|
||||
SYSTEM_PASSWORD = os.environ.get('SYSTEM_PASSWORD', 'admin')
|
||||
DEFAULT_ADMIN_USERNAME = os.environ.get('DEFAULT_ADMIN_USERNAME', 'admin')
|
||||
DEFAULT_ADMIN_PASSWORD = os.environ.get('DEFAULT_ADMIN_PASSWORD', 'admin123')
|
||||
UPLOAD_FOLDER = os.environ.get('UPLOAD_FOLDER', 'uploads')
|
||||
|
@ -4,3 +4,4 @@ Flask-Login
|
||||
Flask-SocketIO
|
||||
eventlet
|
||||
Werkzeug
|
||||
Pillow
|
56
static/js/list_guest.js
Normal file
56
static/js/list_guest.js
Normal file
@ -0,0 +1,56 @@
|
||||
let currentItemId = null;
|
||||
|
||||
function openNoteModal(event, itemId) {
|
||||
event.stopPropagation();
|
||||
currentItemId = itemId;
|
||||
// Pobierz notatkę z HTML-a, jeśli chcesz pokazywać aktualną (opcjonalnie)
|
||||
const noteEl = document.querySelector(`#item-${itemId} small`);
|
||||
document.getElementById('noteText').value = noteEl ? noteEl.innerText : "";
|
||||
const modal = new bootstrap.Modal(document.getElementById('noteModal'));
|
||||
modal.show();
|
||||
}
|
||||
|
||||
function submitNote(e) {
|
||||
e.preventDefault();
|
||||
const text = document.getElementById('noteText').value;
|
||||
|
||||
if (currentItemId !== null) {
|
||||
socket.emit('update_note', { item_id: currentItemId, note: text });
|
||||
|
||||
const modal = bootstrap.Modal.getInstance(document.getElementById('noteModal'));
|
||||
modal.hide();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
document.querySelectorAll('.clickable-item').forEach(item => {
|
||||
item.addEventListener('click', function(e) {
|
||||
// Jeśli klik w button (np. Notatka), nie zaznaczaj
|
||||
if (!e.target.closest('button') && e.target.tagName.toLowerCase() !== 'input') {
|
||||
const checkbox = this.querySelector('input[type="checkbox"]');
|
||||
|
||||
if (checkbox.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (checkbox.checked) {
|
||||
socket.emit('uncheck_item', { item_id: parseInt(this.id.replace('item-', ''), 10) });
|
||||
} else {
|
||||
socket.emit('check_item', { item_id: parseInt(this.id.replace('item-', ''), 10) });
|
||||
}
|
||||
|
||||
checkbox.disabled = true;
|
||||
this.classList.add('opacity-50');
|
||||
|
||||
let existingSpinner = this.querySelector('.spinner-border');
|
||||
if (!existingSpinner) {
|
||||
const spinner = document.createElement('span');
|
||||
spinner.className = 'spinner-border spinner-border-sm ms-2';
|
||||
spinner.setAttribute('role', 'status');
|
||||
spinner.setAttribute('aria-hidden', 'true');
|
||||
checkbox.parentElement.appendChild(spinner);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
@ -36,7 +36,7 @@ function setupList(listId, username) {
|
||||
item.onclick = () => {
|
||||
newItemInput.value = s;
|
||||
suggestionsBox.innerHTML = '';
|
||||
newItemInput.focus();
|
||||
//newItemInput.focus();
|
||||
};
|
||||
suggestionsBox.appendChild(item);
|
||||
});
|
||||
@ -119,6 +119,31 @@ function setupList(listId, username) {
|
||||
updateProgressBar();
|
||||
});
|
||||
|
||||
socket.on('note_updated', data => {
|
||||
const itemEl = document.getElementById(`item-${data.item_id}`);
|
||||
if (itemEl) {
|
||||
// Szukamy <small> w całym elemencie
|
||||
let noteEl = itemEl.querySelector('small');
|
||||
if (noteEl) {
|
||||
noteEl.innerHTML = `[ Notatka: <b>${data.note}</b> ]`;
|
||||
} else {
|
||||
const newNote = document.createElement('small');
|
||||
newNote.className = 'text-danger ms-4';
|
||||
newNote.innerHTML = `[ Notatka: <b>${data.note}</b> ]`;
|
||||
|
||||
// Znajdź wrapper flex-column
|
||||
const flexColumn = itemEl.querySelector('.d-flex.flex-column');
|
||||
if (flexColumn) {
|
||||
flexColumn.appendChild(newNote);
|
||||
} else {
|
||||
// fallback: dodaj do elementu
|
||||
itemEl.appendChild(newNote);
|
||||
}
|
||||
}
|
||||
}
|
||||
showToast('Notatka zaktualizowana!');
|
||||
});
|
||||
|
||||
socket.on('item_edited', data => {
|
||||
const nameSpan = document.getElementById(`name-${data.item_id}`);
|
||||
if (nameSpan) {
|
||||
@ -208,3 +233,4 @@ function showToast(message) {
|
||||
toastContainer.appendChild(toast);
|
||||
setTimeout(() => { toast.remove(); }, 1750);
|
||||
}
|
||||
|
||||
|
@ -18,6 +18,7 @@
|
||||
<div class="d-flex flex-wrap gap-2 mb-4">
|
||||
<a href="/admin/users" class="btn btn-outline-light">👥 Lista użytkowników</a>
|
||||
<a href="/admin/add_user" class="btn btn-success">➕ Dodaj użytkownika</a>
|
||||
<a href="/admin/receipts" class="btn btn-outline-light">📸 Wszystkie paragony</a>
|
||||
<a href="/admin/delete_all_lists" class="btn btn-danger">🗑️ Usuń wszystkie listy</a>
|
||||
</div>
|
||||
|
||||
|
39
templates/admin/receipts.html
Normal file
39
templates/admin/receipts.html
Normal file
@ -0,0 +1,39 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Wszystkie paragony{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center flex-wrap mb-4">
|
||||
<h2 class="mb-2">📸 Wszystkie paragony</h2>
|
||||
<a href="/admin" class="btn btn-outline-secondary">← Powrót do panelu</a>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
{% for img in image_files %}
|
||||
{% set list_id = img.split('_')[1] if '_' in img else None %}
|
||||
{% set file_path = (upload_folder ~ '/' ~ img) %}
|
||||
{% set file_size = (file_path | filesizeformat) %}
|
||||
{% set upload_time = (file_path | filemtime) %}
|
||||
<div class="col-6 col-md-4 col-lg-3">
|
||||
<div class="card bg-dark text-white h-100">
|
||||
<a href="{{ url_for('uploaded_file', filename=img) }}" data-lightbox="receipts" data-title="{{ img }}">
|
||||
<img src="{{ url_for('uploaded_file', filename=img) }}" class="card-img-top" style="object-fit: cover; height: 200px;">
|
||||
</a>
|
||||
<div class="card-body text-center">
|
||||
<p class="small text-truncate mb-1">{{ img }}</p>
|
||||
<p class="small mb-1">Rozmiar: {{ file_size }}</p>
|
||||
<p class="small mb-1">Wgrano: {{ upload_time.strftime('%Y-%m-%d %H:%M') }}</p>
|
||||
{% if list_id %}
|
||||
<a href="{{ url_for('view_list', list_id=list_id|int) }}" class="btn btn-sm btn-outline-light w-100 mb-2">🔗 Lista #{{ list_id }}</a>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('delete_receipt', filename=img) }}" class="btn btn-sm btn-outline-danger w-100">🗑️ Usuń</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% if not image_files %}
|
||||
<p class="text-muted mt-3">Brak wgranych zdjęć.</p>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
@ -5,6 +5,8 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}Live Lista Zakupów{% endblock %}</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css">
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/lightbox2/2.11.4/css/lightbox.min.css" rel="stylesheet">
|
||||
|
||||
<script src="https://cdn.socket.io/4.6.1/socket.io.min.js"></script>
|
||||
<script src="{{ url_for('static_bp.serve_live_js') }}?v={{ time.time() | int }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/toasts.js') }}"></script>
|
||||
@ -45,7 +47,9 @@
|
||||
{% endwith %}
|
||||
});
|
||||
</script>
|
||||
|
||||
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/lightbox2/2.11.4/js/lightbox.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
@ -26,24 +26,47 @@
|
||||
|
||||
<ul id="items" class="list-group mb-3">
|
||||
{% for item in items %}
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center flex-wrap {% if item.purchased %}bg-success text-white{% else %}bg-light text-white{% endif %}" id="item-{{ item.id }}">
|
||||
<div class="d-flex align-items-center flex-wrap gap-2">
|
||||
<input type="checkbox" {% if item.purchased %}checked{% endif %}>
|
||||
<span id="name-{{ item.id }}" class="{% if item.purchased %}text-white{% else %}text-white{% endif %}">{{ item.name }}</span>
|
||||
</div>
|
||||
<div class="mt-2 mt-md-0">
|
||||
<button class="btn btn-sm btn-outline-warning me-1" onclick="editItem({{ item.id }}, '{{ item.name }}')">✏️ Edytuj</button>
|
||||
<button class="btn btn-sm btn-outline-danger" onclick="deleteItem({{ item.id }})">🗑️ Usuń</button>
|
||||
</div>
|
||||
</li>
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center flex-wrap {% if item.purchased %}bg-success text-white{% else %}bg-light text-white{% endif %}" id="item-{{ item.id }}">
|
||||
<div class="d-flex align-items-center flex-wrap gap-2 flex-grow-1">
|
||||
<input type="checkbox" {% if item.purchased %}checked{% endif %}>
|
||||
<span id="name-{{ item.id }}" class="{% if item.purchased %}text-white{% else %}text-white{% endif %}">{{ item.name }}</span>
|
||||
{% if item.note %}
|
||||
<small class="text-danger">[ Notatka: <b>{{ item.note }}</b> ] </small>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="mt-2 mt-md-0 d-flex gap-1">
|
||||
<button class="btn btn-sm btn-outline-warning" onclick="editItem({{ item.id }}, '{{ item.name }}')">✏️ Edytuj</button>
|
||||
<button class="btn btn-sm btn-outline-danger" onclick="deleteItem({{ item.id }})">🗑️ Usuń</button>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
|
||||
<div class="input-group mb-2 position-relative">
|
||||
<input id="newItem" class="form-control" placeholder="Nowy produkt">
|
||||
<button onclick="addItem({{ list.id }})" class="btn btn-success">➕ Dodaj</button>
|
||||
</div>
|
||||
|
||||
{% set receipt_pattern = 'list_' ~ list.id %}
|
||||
{% if receipt_files %}
|
||||
<hr>
|
||||
<h5 class="mt-4">📸 Paragony dodane do tej listy</h5>
|
||||
<div class="row g-3 mt-2">
|
||||
{% 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;">
|
||||
</a>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<hr>
|
||||
<p class="text-muted">Brak wgranych paragonów do tej listy.</p>
|
||||
{% endif %}
|
||||
|
||||
|
||||
<div id="toast-container" class="toast-container position-fixed bottom-0 end-0 p-3"></div>
|
||||
|
||||
<script>
|
||||
|
@ -9,12 +9,16 @@
|
||||
|
||||
<ul id="items" class="list-group mb-3">
|
||||
{% for item in items %}
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center flex-wrap clickable-item {% if item.purchased %}bg-success text-white{% else %}bg-light text-white{% endif %}" id="item-{{ item.id }}">
|
||||
<div class="d-flex align-items-center gap-3 flex-grow-1">
|
||||
<input type="checkbox" class="form-check-input large-checkbox" {% if item.purchased %}checked{% endif %}>
|
||||
<span id="name-{{ item.id }}" class="{% if item.purchased %}text-white{% else %}text-white{% endif %}">{{ item.name }}</span>
|
||||
</div>
|
||||
</li>
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center flex-wrap clickable-item {% if item.purchased %}bg-success text-white{% else %}bg-light text-white{% endif %}" id="item-{{ item.id }}">
|
||||
<div class="d-flex align-items-center gap-3 flex-grow-1">
|
||||
<input type="checkbox" class="form-check-input large-checkbox" {% if item.purchased %}checked{% endif %}>
|
||||
<span id="name-{{ item.id }}" class="{% if item.purchased %}text-white{% else %}text-white{% endif %}">{{ item.name }}</span>
|
||||
{% if item.note %}
|
||||
<small class="text-danger ms-4">[ Notatka: <b>{{ item.note }}</b> ]</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-outline-info" onclick="openNoteModal(event, {{ item.id }})">📝 Notatka</button>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
@ -25,40 +29,61 @@
|
||||
|
||||
<div id="toast-container" class="toast-container position-fixed bottom-0 end-0 p-3"></div>
|
||||
|
||||
{% set receipt_pattern = 'list_' ~ list.id %}
|
||||
{% if receipt_files %}
|
||||
<hr>
|
||||
<h5 class="mt-4">📸 Paragony dodane do tej listy</h5>
|
||||
<div class="row g-3 mt-2">
|
||||
{% 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;">
|
||||
</a>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<hr>
|
||||
<p class="text-muted">Brak wgranych paragonów do tej listy.</p>
|
||||
{% endif %}
|
||||
|
||||
<hr>
|
||||
<h5>📤 Dodaj zdjęcie paragonu</h5>
|
||||
<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" id="receiptInput">
|
||||
<button type="submit" class="btn btn-success">➕ Wgraj</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Modal notatki -->
|
||||
<div class="modal fade" id="noteModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content bg-dark text-white">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Dodaj notatkę</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Zamknij"></button>
|
||||
</div>
|
||||
<form id="noteForm" onsubmit="submitNote(event)">
|
||||
<div class="modal-body">
|
||||
<textarea id="noteText" class="form-control" rows="4" placeholder="Np. 'Nie było, zamieniłem na inny'"></textarea>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Anuluj</button>
|
||||
<button type="submit" class="btn btn-success">💾 Zapisz</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
setupList({{ list.id }}, 'Gość');
|
||||
|
||||
document.querySelectorAll('.clickable-item').forEach(item => {
|
||||
item.addEventListener('click', function(e) {
|
||||
if (e.target.tagName.toLowerCase() !== 'input') {
|
||||
const checkbox = this.querySelector('input[type="checkbox"]');
|
||||
|
||||
if (checkbox.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (checkbox.checked) {
|
||||
socket.emit('uncheck_item', { item_id: parseInt(this.id.replace('item-', ''), 10) });
|
||||
} else {
|
||||
socket.emit('check_item', { item_id: parseInt(this.id.replace('item-', ''), 10) });
|
||||
}
|
||||
|
||||
checkbox.disabled = true;
|
||||
this.classList.add('opacity-50');
|
||||
|
||||
let existingSpinner = this.querySelector('.spinner-border');
|
||||
if (!existingSpinner) {
|
||||
const spinner = document.createElement('span');
|
||||
spinner.className = 'spinner-border spinner-border-sm ms-2';
|
||||
spinner.setAttribute('role', 'status');
|
||||
spinner.setAttribute('aria-hidden', 'true');
|
||||
checkbox.parentElement.appendChild(spinner);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
const LIST_ID = {{ list.id }};
|
||||
setupList(LIST_ID, 'Gość');
|
||||
</script>
|
||||
|
||||
<script src="{{ url_for('static', filename='js/list_guest.js') }}"></script>
|
||||
|
||||
<style>
|
||||
.large-checkbox {
|
||||
width: 1.5em;
|
||||
|
@ -4,7 +4,6 @@
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center flex-wrap mb-4">
|
||||
<h2 class="mb-2">🔑 Hasło do systemu</h2>
|
||||
<a href="{{ url_for('index_guest') }}" class="btn btn-outline-secondary">← Powrót do list</a>
|
||||
</div>
|
||||
|
||||
<div class="card bg-dark text-white">
|
||||
|
Reference in New Issue
Block a user