8 Commits

Author SHA1 Message Date
Mateusz Gruszczyński
0187f1d654 poprawki 2025-08-16 13:33:53 +02:00
Mateusz Gruszczyński
a3bf47ecc3 poprawki 2025-08-16 13:31:51 +02:00
Mateusz Gruszczyński
2edbd6475f poprawki 2025-08-16 13:28:09 +02:00
Mateusz Gruszczyński
cd8d418371 poprawki 2025-08-16 13:23:29 +02:00
Mateusz Gruszczyński
c78b5315bb poprawki 2025-08-16 13:14:45 +02:00
Mateusz Gruszczyński
b6502fedfc poprawki 2025-08-16 13:10:21 +02:00
Mateusz Gruszczyński
e3b180fba7 sortowanie_w_mass_add 2025-08-16 12:32:09 +02:00
Mateusz Gruszczyński
529130a622 sortowanie_w_mass_add 2025-08-16 12:22:22 +02:00
3 changed files with 227 additions and 128 deletions

60
app.py
View File

@@ -1951,47 +1951,39 @@ def suggest_products():
@app.route("/all_products")
def all_products():
query = request.args.get("q", "")
sort = request.args.get("sort", "popularity")
limit = request.args.get("limit", type=int) or 100
offset = request.args.get("offset", type=int) or 0
top_products_query = SuggestedProduct.query
if query:
top_products_query = top_products_query.filter(
SuggestedProduct.name.ilike(f"%{query}%")
)
ItemAlias = aliased(Item)
SuggestedAlias = aliased(SuggestedProduct)
top_products = (
db.session.query(
func.lower(Item.name).label("name"), func.sum(Item.quantity).label("count")
)
.join(ShoppingList, ShoppingList.id == Item.list_id)
.filter(Item.purchased.is_(True))
.group_by(func.lower(Item.name))
.order_by(func.sum(Item.quantity).desc())
.limit(5)
.all()
)
base_query = db.session.query(
func.lower(func.trim(ItemAlias.name)).label("normalized_name"),
func.count(func.distinct(ItemAlias.list_id)).label("count"),
func.min(ItemAlias.name).label("original_name")
).join(
SuggestedAlias,
func.lower(func.trim(ItemAlias.name)) == func.lower(func.trim(SuggestedAlias.name))
).group_by("normalized_name")
top_names = [s.name for s in top_products]
rest_query = SuggestedProduct.query
if query:
rest_query = rest_query.filter(SuggestedProduct.name.ilike(f"%{query}%"))
if sort == "popularity":
base_query = base_query.order_by(func.count(func.distinct(ItemAlias.list_id)).desc(), "normalized_name")
else:
base_query = base_query.order_by("normalized_name")
if top_names:
rest_query = rest_query.filter(~SuggestedProduct.name.in_(top_names))
results = base_query.offset(offset).limit(limit).all()
rest_products = rest_query.order_by(SuggestedProduct.name.asc()).limit(200).all()
total_count = db.session.query(func.count()).select_from(
base_query.subquery()
).scalar()
all_names = top_names + [s.name for s in rest_products]
products = [{"name": row.original_name, "count": row.count} for row in results]
seen = set()
unique_names = []
for name in all_names:
name_lower = name.strip().lower()
if name_lower not in seen:
unique_names.append(name)
seen.add(name_lower)
return {"allproducts": unique_names}
return jsonify({
"products": products,
"total_count": total_count
})
@app.route("/upload_receipt/<int:list_id>", methods=["POST"])

View File

@@ -1,116 +1,224 @@
document.addEventListener('DOMContentLoaded', function () {
const modal = document.getElementById('massAddModal');
const productList = document.getElementById('mass-add-list');
const sortBar = document.getElementById('sort-bar');
const productCountDisplay = document.getElementById('product-count');
const modalBody = modal?.querySelector('.modal-body');
// Funkcja normalizacji (usuwa diakrytyki i zamienia na lowercase)
function normalize(str) {
return str.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase();
return str?.trim().toLowerCase() || '';
}
modal.addEventListener('show.bs.modal', async function () {
let addedProducts = new Set();
document.querySelectorAll('#items li').forEach(li => {
if (li.dataset.name) {
addedProducts.add(normalize(li.dataset.name));
let sortMode = 'popularity';
let limit = 100;
let offset = 0;
let loading = false;
let reachedEnd = false;
let allProducts = [];
let addedProducts = new Set();
function renderSortBar() {
if (!sortBar) return;
sortBar.innerHTML = `
Sortuj: <a href="#" id="sort-popularity" ${sortMode === "popularity" ? 'style="font-weight:bold"' : ''}>Popularność</a> |
<a href="#" id="sort-alphabetical" ${sortMode === "alphabetical" ? 'style="font-weight:bold"' : ''}>Alfabetycznie</a>
`;
document.getElementById('sort-popularity').onclick = (e) => {
e.preventDefault();
if (sortMode !== 'popularity') {
sortMode = 'popularity';
resetAndFetchProducts();
}
});
};
document.getElementById('sort-alphabetical').onclick = (e) => {
e.preventDefault();
if (sortMode !== 'alphabetical') {
sortMode = 'alphabetical';
resetAndFetchProducts();
}
};
}
function resetAndFetchProducts() {
offset = 0;
reachedEnd = false;
allProducts = [];
productList.innerHTML = '';
fetchProducts(true);
renderSortBar();
if (productCountDisplay) productCountDisplay.textContent = '';
}
async function fetchProducts(reset = false) {
if (loading || reachedEnd) return;
loading = true;
if (!reset) {
const loadingLi = document.createElement('li');
loadingLi.className = 'list-group-item bg-dark text-light loading';
loadingLi.textContent = 'Ładowanie...';
productList.appendChild(loadingLi);
}
productList.innerHTML = '<li class="list-group-item bg-dark text-light">Ładowanie...</li>';
try {
const res = await fetch('/all_products');
const res = await fetch(`/all_products?sort=${sortMode}&limit=${limit}&offset=${offset}`);
const data = await res.json();
const allproducts = data.allproducts;
productList.innerHTML = '';
allproducts.forEach(name => {
const li = document.createElement('li');
li.className = 'list-group-item d-flex justify-content-between align-items-center bg-dark text-light';
const products = data.products || [];
if (addedProducts.has(normalize(name))) {
const nameSpan = document.createElement('span');
nameSpan.textContent = name;
li.appendChild(nameSpan);
if (products.length < limit) reachedEnd = true;
allProducts = reset ? products : allProducts.concat(products);
li.classList.add('opacity-50');
const badge = document.createElement('span');
badge.className = 'badge bg-success ms-auto';
badge.textContent = 'Dodano';
li.appendChild(badge);
} else {
const nameSpan = document.createElement('span');
nameSpan.textContent = name;
nameSpan.style.flex = '1 1 auto';
li.appendChild(nameSpan);
const loadingEl = productList.querySelector('.loading');
if (loadingEl) loadingEl.remove();
const qtyWrapper = document.createElement('div');
qtyWrapper.className = 'd-flex align-items-center ms-2 quantity-controls';
if (reset && products.length === 0) {
const emptyLi = document.createElement('li');
emptyLi.className = 'list-group-item text-muted bg-dark';
emptyLi.textContent = 'Brak produktów do wyświetlenia.';
productList.appendChild(emptyLi);
} else {
renderProducts(products);
}
const minusBtn = document.createElement('button');
minusBtn.type = 'button';
minusBtn.className = 'btn btn-outline-light btn-sm px-2';
minusBtn.textContent = '';
minusBtn.onclick = () => {
qty.value = Math.max(1, parseInt(qty.value) - 1);
};
offset += limit;
const qty = document.createElement('input');
qty.type = 'number';
qty.min = 1;
qty.value = 1;
qty.className = 'form-control text-center p-1 rounded';
qty.style.width = '50px';
qty.style.margin = '0 2px';
qty.title = 'Ilość';
if (productCountDisplay) {
productCountDisplay.textContent = `Wyświetlono ${allProducts.length} produktów.`;
}
const plusBtn = document.createElement('button');
plusBtn.type = 'button';
plusBtn.className = 'btn btn-outline-light btn-sm px-2';
plusBtn.textContent = '+';
plusBtn.onclick = () => {
qty.value = parseInt(qty.value) + 1;
};
qtyWrapper.appendChild(minusBtn);
qtyWrapper.appendChild(qty);
qtyWrapper.appendChild(plusBtn);
const btn = document.createElement('button');
btn.className = 'btn btn-sm btn-primary ms-4';
btn.textContent = '+';
btn.onclick = () => {
const quantity = parseInt(qty.value) || 1;
socket.emit('add_item', { list_id: LIST_ID, name: name, quantity: quantity });
};
li.appendChild(qtyWrapper);
li.appendChild(btn);
}
productList.appendChild(li);
});
const statsEl = document.getElementById('massAddProductStats');
if (statsEl) {
statsEl.textContent = `(${allProducts.length} z ${data.total_count})`;
}
} catch (err) {
productList.innerHTML = '<li class="list-group-item text-danger bg-dark">Błąd ładowania danych</li>';
const loadingEl = productList.querySelector('.loading');
if (loadingEl) loadingEl.remove();
const errorLi = document.createElement('li');
errorLi.className = 'list-group-item text-danger bg-dark';
errorLi.textContent = 'Błąd ładowania danych';
productList.appendChild(errorLi);
}
loading = false;
}
function getAlreadyAddedProducts() {
const set = new Set();
document.querySelectorAll('#items li').forEach(li => {
if (li.dataset.name) {
set.add(normalize(li.dataset.name));
}
});
return set;
}
function renderProducts(products) {
addedProducts = getAlreadyAddedProducts();
const existingNames = new Set();
document.querySelectorAll('#mass-add-list li').forEach(li => {
const name = li.querySelector('span')?.textContent;
if (name) existingNames.add(normalize(name));
});
products.forEach(product => {
const name = typeof product === "object" ? product.name : product;
const normName = normalize(name);
if (existingNames.has(normName)) return;
existingNames.add(normName);
const li = document.createElement('li');
li.className = 'list-group-item d-flex justify-content-between align-items-center bg-dark text-light';
if (addedProducts.has(normName)) {
const nameSpan = document.createElement('span');
nameSpan.textContent = name;
li.appendChild(nameSpan);
li.classList.add('opacity-50');
const badge = document.createElement('span');
badge.className = 'badge bg-success ms-auto';
badge.textContent = 'Dodano';
li.appendChild(badge);
} else {
const nameSpan = document.createElement('span');
nameSpan.textContent = name;
nameSpan.style.flex = '1 1 auto';
li.appendChild(nameSpan);
const qtyWrapper = document.createElement('div');
qtyWrapper.className = 'd-flex align-items-center ms-2 quantity-controls';
const minusBtn = document.createElement('button');
minusBtn.type = 'button';
minusBtn.className = 'btn btn-outline-light btn-sm px-2';
minusBtn.textContent = '';
const qty = document.createElement('input');
qty.type = 'number';
qty.min = 1;
qty.value = 1;
qty.className = 'form-control text-center p-1 rounded';
qty.style.width = '50px';
qty.style.margin = '0 2px';
qty.title = 'Ilość';
const plusBtn = document.createElement('button');
plusBtn.type = 'button';
plusBtn.className = 'btn btn-outline-light btn-sm px-2';
plusBtn.textContent = '+';
minusBtn.onclick = () => {
qty.value = Math.max(1, parseInt(qty.value) - 1);
};
plusBtn.onclick = () => {
qty.value = parseInt(qty.value) + 1;
};
qtyWrapper.append(minusBtn, qty, plusBtn);
const btn = document.createElement('button');
btn.className = 'btn btn-sm btn-primary ms-4';
btn.textContent = '+';
btn.onclick = () => {
const quantity = parseInt(qty.value) || 1;
socket.emit('add_item', { list_id: LIST_ID, name: name, quantity: quantity });
};
li.append(qtyWrapper, btn);
}
productList.appendChild(li);
});
}
if (modalBody) {
modalBody.addEventListener('scroll', function () {
if (!loading && !reachedEnd && (modalBody.scrollTop + modalBody.clientHeight > modalBody.scrollHeight - 80)) {
fetchProducts(false);
}
});
}
modal.addEventListener('show.bs.modal', function () {
resetAndFetchProducts();
});
renderSortBar();
socket.on('item_added', data => {
document.querySelectorAll('#mass-add-list li').forEach(li => {
const itemName = li.firstChild?.textContent.trim();
if (normalize(itemName) === normalize(data.name) && !li.classList.contains('opacity-50')) {
li.classList.add('opacity-50');
// Usuń poprzednie przyciski
li.querySelectorAll('button').forEach(btn => btn.remove());
const quantityControls = li.querySelector('.quantity-controls');
if (quantityControls) quantityControls.remove();
// Badge "Dodano"
const badge = document.createElement('span');
badge.className = 'badge bg-success';
badge.textContent = 'Dodano';
// Grupowanie przycisku + licznika
const btnGroup = document.createElement('div');
btnGroup.className = 'btn-group btn-group-sm me-2';
btnGroup.role = 'group';
@@ -124,17 +232,13 @@ document.addEventListener('DOMContentLoaded', function () {
let secondsLeft = 15;
timerBtn.textContent = `${secondsLeft}s`;
btnGroup.appendChild(undoBtn);
btnGroup.appendChild(timerBtn);
btnGroup.append(undoBtn, timerBtn);
// Kontener na prawą stronę
const rightWrapper = document.createElement('div');
rightWrapper.className = 'd-flex align-items-center gap-2 ms-auto';
rightWrapper.appendChild(btnGroup);
rightWrapper.appendChild(badge);
rightWrapper.append(btnGroup, badge);
li.appendChild(rightWrapper);
// Odliczanie
const intervalId = setInterval(() => {
secondsLeft--;
if (secondsLeft > 0) {
@@ -145,14 +249,12 @@ document.addEventListener('DOMContentLoaded', function () {
}
}, 1000);
// Obsługa cofnięcia
undoBtn.onclick = () => {
clearInterval(intervalId);
btnGroup.remove();
badge.remove();
li.classList.remove('opacity-50');
// Przywróć kontrolki ilości
const qtyWrapper = document.createElement('div');
qtyWrapper.className = 'd-flex align-items-center ms-2 quantity-controls';
@@ -185,7 +287,6 @@ document.addEventListener('DOMContentLoaded', function () {
qtyWrapper.append(minusBtn, qty, plusBtn);
li.appendChild(qtyWrapper);
// Dodaj przycisk dodawania
const addBtn = document.createElement('button');
addBtn.className = 'btn btn-sm btn-primary ms-4';
addBtn.textContent = '+';
@@ -199,13 +300,9 @@ document.addEventListener('DOMContentLoaded', function () {
};
li.appendChild(addBtn);
// Usuń z listy
socket.emit('delete_item', { item_id: data.id });
};
}
});
});
});

View File

@@ -223,12 +223,21 @@
<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="massAddModalLabel">Masowe dodawanie produktów</h5>
<h5 class="modal-title" id="massAddModalLabel">
Masowe dodawanie produktów
<span id="massAddProductStats" class="ms-2 text-muted" style="font-size: 85%; font-weight: normal;"></span>
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Zamknij"></button>
</div>
<div class="modal-body">
<ul id="mass-add-list" class="list-group">
</ul>
<!-- SORTOWANIE i LICZNIK -->
<div id="sort-bar" class="mb-2"></div>
<div id="product-count" class="text-muted mb-1" style="font-size:90%"></div>
<!-- LISTA PRODUKTÓW -->
<ul id="mass-add-list" class="list-group"></ul>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-light" data-bs-dismiss="modal">Zamknij</button>
@@ -237,6 +246,7 @@
</div>
</div>
{% block scripts %}
<script src="{{ url_for('static_bp.serve_js_lib', filename='Sortable.min.js') }}"></script>
<script>