Compare commits
8 Commits
master
...
0187f1d654
Author | SHA1 | Date | |
---|---|---|---|
![]() |
0187f1d654 | ||
![]() |
a3bf47ecc3 | ||
![]() |
2edbd6475f | ||
![]() |
cd8d418371 | ||
![]() |
c78b5315bb | ||
![]() |
b6502fedfc | ||
![]() |
e3b180fba7 | ||
![]() |
529130a622 |
60
app.py
60
app.py
@@ -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"])
|
||||
|
@@ -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 });
|
||||
};
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
|
||||
|
@@ -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>
|
||||
|
Reference in New Issue
Block a user