From a1dcf36d1f11620744039b628b32ba02f5d976e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Tue, 2 Jun 2026 22:49:52 +0200 Subject: [PATCH 1/2] new functions --- shopping_app/helpers.py | 90 ++++++++++++++ shopping_app/models.py | 4 + shopping_app/sockets.py | 57 +++++---- shopping_app/static/css/split/layout.css | 24 +++- shopping_app/static/css/split/pages.css | 8 +- shopping_app/static/css/split/responsive.css | 60 +++++++--- shopping_app/static/js/functions.js | 92 ++++++++++---- shopping_app/static/js/list_item_form.js | 19 +++ shopping_app/static/js/live.js | 5 +- shopping_app/static/js/mass_add.js | 119 +++++++++++++++---- shopping_app/static/js/receipt_section.js | 13 +- shopping_app/templates/list.html | 64 +++++----- shopping_app/templates/list_share.html | 59 ++++++++- 13 files changed, 490 insertions(+), 124 deletions(-) create mode 100644 shopping_app/static/js/list_item_form.js diff --git a/shopping_app/helpers.py b/shopping_app/helpers.py index 1c60371..2b7ead2 100644 --- a/shopping_app/helpers.py +++ b/shopping_app/helpers.py @@ -249,6 +249,8 @@ def duplicate_list_for_schedule(source_list: ShoppingList, scheduled_for: dateti list_id=new_list.id, name=item.name, quantity=item.quantity or 1, + quantity_value=getattr(item, "quantity_value", None) or item.quantity or 1, + quantity_unit=normalize_quantity_unit(getattr(item, "quantity_unit", None)), note=item.note, position=item.position or 0, added_at=scheduled_for, @@ -365,6 +367,8 @@ def create_list_from_template_at_schedule(template: ListTemplate, owner: User, s list_id=new_list.id, name=item.name, quantity=item.quantity or 1, + quantity_value=getattr(item, "quantity_value", None) or item.quantity or 1, + quantity_unit=normalize_quantity_unit(getattr(item, "quantity_unit", None)), note=item.note, position=item.position or idx, added_by=owner.id, @@ -574,6 +578,8 @@ def create_template_from_list(source_list: ShoppingList, created_by: int | None template_id=template.id, name=item.name, quantity=item.quantity or 1, + quantity_value=getattr(item, "quantity_value", None) or item.quantity or 1, + quantity_unit=normalize_quantity_unit(getattr(item, "quantity_unit", None)), note=item.note, position=idx + 1, )) @@ -606,6 +612,8 @@ def create_list_from_template(template: ListTemplate, owner: User, title: str | list_id=new_list.id, name=item.name, quantity=item.quantity or 1, + quantity_value=getattr(item, "quantity_value", None) or item.quantity or 1, + quantity_unit=normalize_quantity_unit(getattr(item, "quantity_unit", None)), note=item.note, position=idx + 1, added_by=owner.id, @@ -641,6 +649,87 @@ def parse_api_date_range(start_date_str: str | None, end_date_str: str | None): return start_date, end_date + + +def normalize_quantity_unit(unit): + unit = (unit or "szt").strip().lower() + aliases = { + "szt.": "szt", "sztuki": "szt", "sztuka": "szt", "pcs": "szt", + "kilogram": "kg", "kilogramy": "kg", + "gram": "g", "gramy": "g", + "litr": "l", "litry": "l", + "mililitr": "ml", "mililitry": "ml", + "opak": "opak.", "op": "opak.", "opakowanie": "opak.", "opakowania": "opak.", + } + unit = aliases.get(unit, unit) + allowed = {"szt", "kg", "g", "l", "ml", "opak."} + return unit if unit in allowed else "szt" + + +def parse_quantity_value(value, fallback=1): + try: + raw = str(value if value is not None else fallback).strip().replace(",", ".") + parsed = float(raw) + if parsed <= 0: + parsed = float(fallback or 1) + except Exception: + parsed = float(fallback or 1) + return max(parsed, 0.001) + + +def legacy_quantity_int(value): + try: + parsed = int(round(float(value))) + except Exception: + parsed = 1 + return max(parsed, 1) + + +def quantity_display_value(item): + value = getattr(item, "quantity_value", None) + if value is None: + value = getattr(item, "quantity", 1) or 1 + try: + value = float(value) + except Exception: + value = 1.0 + if value.is_integer(): + return str(int(value)) + return (f"{value:.3f}".rstrip("0").rstrip(".")).replace(".", ",") + + +def quantity_label(item): + unit = normalize_quantity_unit(getattr(item, "quantity_unit", None)) + value = quantity_display_value(item) + if unit == "szt": + return f"x{value}" + return f"{value} {unit}" + + +def ensure_lightweight_schema_migrations(): + inspector = inspect(db.engine) + table_names = set(inspector.get_table_names()) + targets = [] + for table_name in ("item", "list_template_item"): + if table_name not in table_names: + continue + existing = {c["name"] for c in inspector.get_columns(table_name)} + if "quantity_value" not in existing: + targets.append((table_name, f"ALTER TABLE {table_name} ADD COLUMN quantity_value FLOAT")) + if "quantity_unit" not in existing: + targets.append((table_name, f"ALTER TABLE {table_name} ADD COLUMN quantity_unit VARCHAR(20) DEFAULT 'szt'")) + if not targets: + return + with db.engine.begin() as conn: + touched_tables = set() + for table_name, stmt in targets: + conn.execute(text(stmt)) + touched_tables.add(table_name) + for table_name in touched_tables: + conn.execute(text(f"UPDATE {table_name} SET quantity_value = quantity WHERE quantity_value IS NULL")) + conn.execute(text(f"UPDATE {table_name} SET quantity_unit = 'szt' WHERE quantity_unit IS NULL OR quantity_unit = ''")) + print("[INFO] Zastosowano lekką migrację schematu dla ilości/jednostek produktów") + def set_authorized_cookie(response): secure_flag = app.config["SESSION_COOKIE_SECURE"] max_age = app.config.get("AUTH_COOKIE_MAX_AGE", 86400) @@ -664,6 +753,7 @@ if app.config["SQLALCHEMY_DATABASE_URI"].startswith("sqlite:///"): with app.app_context(): db.create_all() + ensure_lightweight_schema_migrations() # --- Tworzenie admina --- admin_username = DEFAULT_ADMIN_USERNAME diff --git a/shopping_app/models.py b/shopping_app/models.py index 04ea605..02c9568 100644 --- a/shopping_app/models.py +++ b/shopping_app/models.py @@ -73,6 +73,8 @@ class Item(db.Model): purchased = db.Column(db.Boolean, default=False) purchased_at = db.Column(db.DateTime, nullable=True) quantity = db.Column(db.Integer, default=1) + quantity_value = db.Column(db.Float, nullable=True) + quantity_unit = db.Column(db.String(20), default="szt", nullable=True) note = db.Column(db.Text, nullable=True) not_purchased = db.Column(db.Boolean, default=False) not_purchased_reason = db.Column(db.Text, nullable=True) @@ -186,6 +188,8 @@ class ListTemplateItem(db.Model): template_id = db.Column(db.Integer, db.ForeignKey("list_template.id", ondelete="CASCADE"), nullable=False) name = db.Column(db.String(150), nullable=False) quantity = db.Column(db.Integer, default=1) + quantity_value = db.Column(db.Float, nullable=True) + quantity_unit = db.Column(db.String(20), default="szt", nullable=True) note = db.Column(db.Text, nullable=True) position = db.Column(db.Integer, default=0) diff --git a/shopping_app/sockets.py b/shopping_app/sockets.py index f28a199..fba40ff 100644 --- a/shopping_app/sockets.py +++ b/shopping_app/sockets.py @@ -191,25 +191,27 @@ def handle_edit_item(data): item = db.session.get(Item, data["item_id"]) new_name = data["new_name"] - new_quantity = data.get("new_quantity", item.quantity) + new_quantity_value = parse_quantity_value(data.get("quantity_value", data.get("new_quantity", item.quantity))) + new_quantity_unit = normalize_quantity_unit(data.get("quantity_unit", getattr(item, "quantity_unit", None))) if item and new_name.strip(): item.name = new_name.strip() - - try: - new_quantity = int(new_quantity) - if new_quantity < 1: - new_quantity = 1 - except: - new_quantity = 1 - - item.quantity = new_quantity + item.quantity_value = new_quantity_value + item.quantity_unit = new_quantity_unit + item.quantity = legacy_quantity_int(new_quantity_value) db.session.commit() emit( "item_edited", - {"item_id": item.id, "new_name": item.name, "new_quantity": item.quantity}, + { + "item_id": item.id, + "new_name": item.name, + "new_quantity": item.quantity, + "quantity_value": item.quantity_value, + "quantity_unit": item.quantity_unit, + "quantity_label": quantity_label(item), + }, to=str(item.list_id), ) @@ -249,19 +251,14 @@ def handle_disconnect(sid): def handle_add_item(data): list_id = data["list_id"] name = data["name"].strip() - quantity = data.get("quantity", 1) + quantity_value = parse_quantity_value(data.get("quantity_value", data.get("quantity", 1))) + quantity_unit = normalize_quantity_unit(data.get("quantity_unit", "szt")) + quantity = legacy_quantity_int(quantity_value) list_obj = db.session.get(ShoppingList, list_id) if not list_obj: return - try: - quantity = int(quantity) - if quantity < 1: - quantity = 1 - except: - quantity = 1 - existing_item = Item.query.filter( Item.list_id == list_id, func.lower(Item.name) == name.lower(), @@ -269,7 +266,14 @@ def handle_add_item(data): ).first() if existing_item: - existing_item.quantity += quantity + current_value = parse_quantity_value(getattr(existing_item, "quantity_value", None), existing_item.quantity or 1) + current_unit = normalize_quantity_unit(getattr(existing_item, "quantity_unit", None)) + if current_unit == quantity_unit: + existing_item.quantity_value = current_value + quantity_value + else: + existing_item.quantity_value = quantity_value + existing_item.quantity_unit = quantity_unit + existing_item.quantity = legacy_quantity_int(existing_item.quantity_value) db.session.commit() emit( @@ -278,6 +282,9 @@ def handle_add_item(data): "item_id": existing_item.id, "new_name": existing_item.name, "new_quantity": existing_item.quantity, + "quantity_value": existing_item.quantity_value, + "quantity_unit": existing_item.quantity_unit, + "quantity_label": quantity_label(existing_item), }, to=str(list_id), ) @@ -297,6 +304,8 @@ def handle_add_item(data): list_id=list_id, name=name, quantity=quantity, + quantity_value=quantity_value, + quantity_unit=quantity_unit, position=max_position + 1, added_by=user_id, ) @@ -308,7 +317,7 @@ def handle_add_item(data): new_suggestion = SuggestedProduct(name=name) db.session.add(new_suggestion) - log_list_activity(list_id, 'item_added', item_name=new_item.name, actor=current_user if current_user.is_authenticated else None, actor_name=user_name, details=f'ilość: {new_item.quantity}') + log_list_activity(list_id, 'item_added', item_name=new_item.name, actor=current_user if current_user.is_authenticated else None, actor_name=user_name, details=f'ilość: {quantity_label(new_item)}') db.session.commit() emit( @@ -317,6 +326,9 @@ def handle_add_item(data): "id": new_item.id, "name": new_item.name, "quantity": new_item.quantity, + "quantity_value": new_item.quantity_value, + "quantity_unit": new_item.quantity_unit, + "quantity_label": quantity_label(new_item), "added_by": user_name, "added_by_id": user_id, "owner_id": list_obj.owner_id, @@ -412,6 +424,9 @@ def handle_request_full_list(data): "id": item.id, "name": item.name, "quantity": item.quantity, + "quantity_value": getattr(item, "quantity_value", None) or item.quantity or 1, + "quantity_unit": normalize_quantity_unit(getattr(item, "quantity_unit", None)), + "quantity_label": quantity_label(item), "purchased": item.purchased if not item.not_purchased else False, "not_purchased": item.not_purchased, "not_purchased_reason": item.not_purchased_reason, diff --git a/shopping_app/static/css/split/layout.css b/shopping_app/static/css/split/layout.css index 0ca8276..598a441 100644 --- a/shopping_app/static/css/split/layout.css +++ b/shopping_app/static/css/split/layout.css @@ -1333,6 +1333,11 @@ input[type="checkbox"].form-check-input, max-width: 4.5rem; } +.shopping-qty-unit { + flex: 0 0 5.2rem; + max-width: 5.2rem; +} + .shopping-compact-submit { flex: 0 0 auto; width: auto; @@ -1416,6 +1421,12 @@ input[type="checkbox"].form-check-input, text-align: center; } +.shopping-product-input-group > .shopping-qty-unit { + flex: 0 0 5.2rem; + max-width: 5.2rem; + text-align: center; +} + .shopping-expense-input-group > .shopping-compact-submit, .shopping-product-input-group > .shopping-compact-submit { flex: 0 0 auto; @@ -1458,6 +1469,9 @@ input[type="checkbox"].form-check-input, .endpoint-list .shopping-product-input-group > .form-control, .endpoint-list_share .shopping-product-input-group > .form-control, .endpoint-shared_list .shopping-product-input-group > .form-control, +.endpoint-list .shopping-product-input-group > .form-select, +.endpoint-list_share .shopping-product-input-group > .form-select, +.endpoint-shared_list .shopping-product-input-group > .form-select, .endpoint-list .shopping-expense-input-group > .form-control, .endpoint-list_share .shopping-expense-input-group > .form-control, .endpoint-shared_list .shopping-expense-input-group > .form-control, @@ -1484,7 +1498,10 @@ input[type="checkbox"].form-check-input, .endpoint-list .shopping-product-input-group > .shopping-qty-input, .endpoint-list_share .shopping-product-input-group > .shopping-qty-input, -.endpoint-shared_list .shopping-product-input-group > .shopping-qty-input { +.endpoint-shared_list .shopping-product-input-group > .shopping-qty-input, +.endpoint-list .shopping-product-input-group > .shopping-qty-unit, +.endpoint-list_share .shopping-product-input-group > .shopping-qty-unit, +.endpoint-shared_list .shopping-product-input-group > .shopping-qty-unit { border-radius: 0 !important; border-left-width: 0 !important; } @@ -1537,6 +1554,7 @@ input[type="checkbox"].form-check-input, } .endpoint-view_list .shopping-product-input-group > .form-control, +.endpoint-view_list .shopping-product-input-group > .form-select, .endpoint-view_list .shopping-expense-input-group > .form-control, .endpoint-view_list .shopping-product-input-group > .btn, .endpoint-view_list .shopping-expense-input-group > .btn { @@ -1551,7 +1569,8 @@ input[type="checkbox"].form-check-input, border-bottom-right-radius: 0 !important; } -.endpoint-view_list .shopping-product-input-group > .shopping-qty-input { +.endpoint-view_list .shopping-product-input-group > .shopping-qty-input, +.endpoint-view_list .shopping-product-input-group > .shopping-qty-unit { border-radius: 0 !important; border-left-width: 0 !important; } @@ -2323,4 +2342,3 @@ body:not(.sorting-active) .drag-handle { #desktopItemMenu[hidden] { display: none !important; } - diff --git a/shopping_app/static/css/split/pages.css b/shopping_app/static/css/split/pages.css index adb07d4..6c45559 100644 --- a/shopping_app/static/css/split/pages.css +++ b/shopping_app/static/css/split/pages.css @@ -120,14 +120,20 @@ font-size: 1rem; font-weight: 600; color: var(--text-strong); + text-align: right; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .receipt-disclosure__meta { display: flex; align-items: center; + justify-content: flex-end; gap: 12px; margin-left: auto; - flex-shrink: 0; + flex: 0 1 auto; + min-width: 0; } .receipt-disclosure__count { diff --git a/shopping_app/static/css/split/responsive.css b/shopping_app/static/css/split/responsive.css index 9c01677..d23c74c 100644 --- a/shopping_app/static/css/split/responsive.css +++ b/shopping_app/static/css/split/responsive.css @@ -443,19 +443,27 @@ .shopping-product-input-group > .shopping-product-name-input, .shopping-expense-input-group > .shopping-expense-amount-input { - flex: 0 0 60%; + flex: 0 0 46%; min-width: 0; } .shopping-product-input-group > .shopping-qty-input { - flex: 0 0 15%; - max-width: 15%; + flex: 0 0 16%; + max-width: 16%; min-width: 0; } + .shopping-product-input-group > .shopping-qty-unit { + flex: 0 0 18%; + max-width: 18% !important; + min-width: 0; + padding-left: .35rem; + padding-right: .35rem; + } + .shopping-product-input-group > .shopping-compact-submit { - flex: 0 0 25%; - width: 25%; + flex: 0 0 20%; + width: 20%; min-width: 0; padding-left: .55rem; padding-right: .55rem; @@ -489,16 +497,26 @@ .endpoint-list .shopping-product-input-group > .shopping-product-name-input, .endpoint-list_share .shopping-product-input-group > .shopping-product-name-input, .endpoint-shared_list .shopping-product-input-group > .shopping-product-name-input { - flex: 0 0 60% !important; - max-width: 60% !important; + flex: 0 0 46% !important; + max-width: 46% !important; min-width: 0; } .endpoint-list .shopping-product-input-group > .shopping-qty-input, .endpoint-list_share .shopping-product-input-group > .shopping-qty-input, .endpoint-shared_list .shopping-product-input-group > .shopping-qty-input { - flex: 0 0 15% !important; - max-width: 15% !important; + flex: 0 0 16% !important; + max-width: 16% !important; + min-width: 0; + padding-left: .35rem; + padding-right: .35rem; + } + + .endpoint-list .shopping-product-input-group > .shopping-qty-unit, + .endpoint-list_share .shopping-product-input-group > .shopping-qty-unit, + .endpoint-shared_list .shopping-product-input-group > .shopping-qty-unit { + flex: 0 0 18% !important; + max-width: 18% !important; min-width: 0; padding-left: .35rem; padding-right: .35rem; @@ -507,8 +525,8 @@ .endpoint-list .shopping-product-input-group > .shopping-compact-submit, .endpoint-list_share .shopping-product-input-group > .shopping-compact-submit, .endpoint-shared_list .shopping-product-input-group > .shopping-compact-submit { - flex: 0 0 25% !important; - width: 25% !important; + flex: 0 0 20% !important; + width: 20% !important; min-width: 0 !important; padding-left: .4rem; padding-right: .4rem; @@ -553,22 +571,30 @@ @media (max-width: 767.98px){ .endpoint-view_list .shopping-product-input-group > .shopping-product-name-input { - flex: 0 0 60% !important; - max-width: 60% !important; + flex: 0 0 46% !important; + max-width: 46% !important; min-width: 0; } .endpoint-view_list .shopping-product-input-group > .shopping-qty-input { - flex: 0 0 15% !important; - max-width: 15% !important; + flex: 0 0 16% !important; + max-width: 16% !important; + min-width: 0; + padding-left: .35rem; + padding-right: .35rem; + } + + .endpoint-view_list .shopping-product-input-group > .shopping-qty-unit { + flex: 0 0 18% !important; + max-width: 18% !important; min-width: 0; padding-left: .35rem; padding-right: .35rem; } .endpoint-view_list .shopping-product-input-group > .shopping-compact-submit { - flex: 0 0 25% !important; - width: 25% !important; + flex: 0 0 20% !important; + width: 20% !important; min-width: 0 !important; padding-left: .4rem; padding-right: .4rem; diff --git a/shopping_app/static/js/functions.js b/shopping_app/static/js/functions.js index b5a71a8..a9dbc72 100644 --- a/shopping_app/static/js/functions.js +++ b/shopping_app/static/js/functions.js @@ -74,24 +74,42 @@ function updateProgressBar() { if (percentValueEl) percentValueEl.textContent = percent; } +function parseQuantityInput(value, fallback = 1) { + const parsed = parseFloat(String(value ?? fallback).replace(',', '.')); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +function formatQuantityValue(value) { + const parsed = parseQuantityInput(value, 1); + return Number.isInteger(parsed) ? String(parsed) : parsed.toFixed(3).replace(/0+$/, '').replace(/\.$/, ''); +} + +function quantityLabel(value, unit) { + const safeUnit = unit || 'szt'; + const safeValue = formatQuantityValue(value); + return safeUnit === 'szt' ? `x${safeValue}` : `${safeValue} ${safeUnit}`; +} + function addItem(listId) { const name = document.getElementById('newItem').value; const quantityInput = document.getElementById('newQuantity'); - let quantity = 1; - - if (quantityInput) { - quantity = parseInt(quantityInput.value); - if (isNaN(quantity) || quantity < 1) { - quantity = 1; - } - } + const unitInput = document.getElementById('newUnit'); + const quantityValue = parseQuantityInput(quantityInput?.value, 1); + const quantityUnit = unitInput?.value || 'szt'; if (name.trim() === '') return; - socket.emit('add_item', { list_id: listId, name: name, quantity: quantity }); + socket.emit('add_item', { + list_id: listId, + name: name, + quantity: Math.max(1, Math.round(quantityValue)), + quantity_value: quantityValue, + quantity_unit: quantityUnit + }); document.getElementById('newItem').value = ''; if (quantityInput) quantityInput.value = 1; + if (unitInput) unitInput.value = 'szt'; document.getElementById('newItem').focus(); } @@ -101,23 +119,26 @@ function deleteItem(id) { } } -function editItem(id, oldName, oldQuantity) { +function editItem(id, oldName, oldQuantity, oldUnit = 'szt') { const finalName = String(oldName ?? '').trim(); - let newQuantity = parseInt(oldQuantity, 10); + const newQuantity = parseQuantityInput(oldQuantity, 1); + const newUnit = oldUnit || 'szt'; if (!finalName) { showToast('Nazwa produktu nie może być pusta.', 'warning'); return; } - if (isNaN(newQuantity) || newQuantity < 1) { - newQuantity = 1; - } - - socket.emit('edit_item', { item_id: id, new_name: finalName, new_quantity: newQuantity }); + socket.emit('edit_item', { + item_id: id, + new_name: finalName, + new_quantity: Math.max(1, Math.round(newQuantity)), + quantity_value: newQuantity, + quantity_unit: newUnit + }); } -function openEditItemModal(event, id, oldName, oldQuantity) { +function openEditItemModal(event, id, oldName, oldQuantity, oldUnit = 'szt') { if (event && typeof event.stopPropagation === 'function') { event.stopPropagation(); } @@ -126,17 +147,18 @@ function openEditItemModal(event, id, oldName, oldQuantity) { const idInput = document.getElementById('editItemId'); const nameInput = document.getElementById('editItemName'); const quantityInput = document.getElementById('editItemQuantity'); + const unitInput = document.getElementById('editItemUnit'); - if (!modalEl || !idInput || !nameInput || !quantityInput || typeof bootstrap === 'undefined') { - editItem(id, oldName, oldQuantity); + if (!modalEl || !idInput || !nameInput || !quantityInput || !unitInput || typeof bootstrap === 'undefined') { + editItem(id, oldName, oldQuantity, oldUnit); return; } idInput.value = id; nameInput.value = String(oldName ?? '').trim(); - const parsedQuantity = parseInt(oldQuantity, 10); - quantityInput.value = !isNaN(parsedQuantity) && parsedQuantity > 0 ? parsedQuantity : 1; + quantityInput.value = formatQuantityValue(oldQuantity); + unitInput.value = oldUnit || 'szt'; const modal = bootstrap.Modal.getOrCreateInstance(modalEl); modal.show(); @@ -147,6 +169,17 @@ function openEditItemModal(event, id, oldName, oldQuantity) { }, 150); } +function openQuantityModal(event, id, oldName, oldQuantity, oldUnit = 'szt') { + openEditItemModal(event, id, oldName, oldQuantity, oldUnit); + setTimeout(() => { + const quantityInput = document.getElementById('editItemQuantity'); + if (quantityInput) { + quantityInput.focus(); + quantityInput.select(); + } + }, 200); +} + function submitExpense(listId) { const amountInput = document.getElementById('expenseAmount'); const amount = parseFloat(amountInput.value); @@ -389,8 +422,11 @@ function renderItem(item, isShare = window.IS_SHARE, optionsOrShowEditOnly = fal const safeName = escapeHtml(rawName); const nameForEdit = JSON.stringify(rawName); const quantity = Number.isInteger(item.quantity) ? item.quantity : parseInt(item.quantity, 10) || 1; - const quantityBadge = quantity > 1 - ? `x${quantity}` + const quantityValue = parseQuantityInput(item.quantity_value ?? item.quantity, quantity); + const quantityUnit = item.quantity_unit || 'szt'; + const quantityText = item.quantity_label || quantityLabel(quantityValue, quantityUnit); + const quantityBadge = (quantityUnit !== 'szt' || quantityValue !== 1) + ? `${escapeHtml(quantityText)}` : ''; const canEditListItem = !isShare; @@ -417,6 +453,7 @@ function renderItem(item, isShare = window.IS_SHARE, optionsOrShowEditOnly = fal : ''; const iconBtn = 'btn btn-outline-light btn-sm shopping-action-btn'; + const quantityBtn = 'btn btn-outline-light btn-sm shopping-action-btn'; const wideBtn = 'btn btn-outline-light btn-sm shopping-action-btn shopping-action-btn--wide'; const itemNameHtml = canEditListItem ? `` : `${safeName}`; let actionButtons = ''; @@ -436,7 +474,8 @@ function renderItem(item, isShare = window.IS_SHARE, optionsOrShowEditOnly = fal actionButtons += ` ${dragHandleButton} - + + `; } @@ -451,7 +490,8 @@ function renderItem(item, isShare = window.IS_SHARE, optionsOrShowEditOnly = fal if (temporaryShareUndo) { actionButtons += ` - + + `; } else if (canShowShareActions || (!isShare && isOwner)) { actionButtons += ` diff --git a/shopping_app/static/js/list_item_form.js b/shopping_app/static/js/list_item_form.js new file mode 100644 index 0000000..1336458 --- /dev/null +++ b/shopping_app/static/js/list_item_form.js @@ -0,0 +1,19 @@ +document.addEventListener('DOMContentLoaded', function () { + const editItemForm = document.getElementById('editItemForm'); + if (!editItemForm) return; + + editItemForm.addEventListener('submit', function (event) { + event.preventDefault(); + + const itemId = parseInt(document.getElementById('editItemId').value, 10); + const itemName = document.getElementById('editItemName').value; + const itemQuantity = document.getElementById('editItemQuantity').value; + const itemUnit = document.getElementById('editItemUnit').value; + + editItem(itemId, itemName, itemQuantity, itemUnit); + + const modalEl = document.getElementById('editItemModal'); + const modal = bootstrap.Modal.getInstance(modalEl); + if (modal) modal.hide(); + }); +}); diff --git a/shopping_app/static/js/live.js b/shopping_app/static/js/live.js index 513fbd3..6c91f10 100644 --- a/shopping_app/static/js/live.js +++ b/shopping_app/static/js/live.js @@ -235,6 +235,9 @@ function setupList(listId, username) { id: itemId, name: data.new_name, quantity: data.new_quantity, + quantity_value: data.quantity_value ?? data.new_quantity, + quantity_unit: data.quantity_unit || 'szt', + quantity_label: data.quantity_label || quantityLabel(data.quantity_value ?? data.new_quantity, data.quantity_unit || 'szt'), purchased: oldItem ? oldItem.classList.contains('bg-success') : !!cachedItem.purchased, not_purchased: oldItem ? oldItem.classList.contains('bg-warning') : !!cachedItem.not_purchased, not_purchased_reason: cachedItem.not_purchased_reason || '', @@ -252,7 +255,7 @@ function setupList(listId, username) { socket.emit('request_full_list', { list_id: window.LIST_ID }); } - showToast(`Zaktualizowano produkt: ${data.new_name} (x${data.new_quantity})`, 'success'); + showToast(`Zaktualizowano produkt: ${data.new_name} (${data.quantity_label || quantityLabel(data.quantity_value ?? data.new_quantity, data.quantity_unit || 'szt')})`, 'success'); updateProgressBar(); toggleEmptyPlaceholder(); diff --git a/shopping_app/static/js/mass_add.js b/shopping_app/static/js/mass_add.js index 4ce906e..86b07ce 100644 --- a/shopping_app/static/js/mass_add.js +++ b/shopping_app/static/js/mass_add.js @@ -9,6 +9,24 @@ document.addEventListener('DOMContentLoaded', function () { return str?.trim().toLowerCase() || ''; } + function readQuantity(qty, fallback = 1) { + const parsed = parseFloat(String(qty?.value ?? fallback).replace(',', '.')); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; + } + + function makeUnitSelect() { + const unit = document.createElement('select'); + unit.className = 'form-select form-select-sm bg-dark text-white border-secondary'; + unit.style.width = '78px'; + ['szt', 'kg', 'g', 'l', 'ml', 'opak.'].forEach(value => { + const option = document.createElement('option'); + option.value = value; + option.textContent = value; + unit.appendChild(option); + }); + return unit; + } + let sortMode = 'popularity'; let limit = 25; let offset = 0; @@ -105,13 +123,54 @@ document.addEventListener('DOMContentLoaded', function () { } function getAlreadyAddedProducts() { - const set = new Set(); - document.querySelectorAll('#items li').forEach(li => { - if (li.dataset.name) { - set.add(normalize(li.dataset.name)); - } + const map = new Map(); + document.querySelectorAll('#items li[id^="item-"]').forEach(li => { + if (!li.dataset.name) return; + const nameBtn = li.querySelector('[data-item-name]'); + const name = nameBtn?.dataset.itemName || li.dataset.name || ''; + map.set(normalize(li.dataset.name), { + id: parseInt(li.id.replace('item-', ''), 10), + name: name, + quantityValue: readQuantity({ value: nameBtn?.dataset.itemQuantity || 1 }), + unit: nameBtn?.dataset.itemUnit || 'szt' + }); }); - return set; + return map; + } + + function appendQuantityControls(li, initialValue = 1, initialUnit = 'szt') { + 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 = 0.001; + qty.step = 0.001; + qty.value = initialValue || 1; + qty.className = 'form-control text-center p-1 rounded'; + qty.style.width = '62px'; + qty.style.margin = '0 2px'; + qty.title = 'Ilość / waga / objętość'; + + const plusBtn = document.createElement('button'); + plusBtn.type = 'button'; + plusBtn.className = 'btn btn-outline-light btn-sm px-2'; + plusBtn.textContent = '+'; + + const unit = makeUnitSelect(); + unit.value = initialUnit || 'szt'; + + minusBtn.onclick = () => { qty.value = Math.max(0.001, readQuantity(qty) - 1); }; + plusBtn.onclick = () => { qty.value = readQuantity(qty) + 1; }; + + qtyWrapper.append(minusBtn, qty, plusBtn, unit); + li.appendChild(qtyWrapper); + return { qty, unit, qtyWrapper }; } function renderProducts(products) { @@ -135,10 +194,12 @@ document.addEventListener('DOMContentLoaded', function () { if (addedProducts.has(normName)) { const nameSpan = document.createElement('span'); nameSpan.textContent = name; + nameSpan.style.flex = '1 1 auto'; li.appendChild(nameSpan); - li.classList.add('opacity-50'); + li.classList.add('opacity-50', 'border-success'); + const badge = document.createElement('span'); - badge.className = 'badge bg-success ms-auto'; + badge.className = 'badge bg-success ms-2'; badge.textContent = 'Dodano'; li.appendChild(badge); } else { @@ -157,12 +218,15 @@ document.addEventListener('DOMContentLoaded', function () { const qty = document.createElement('input'); qty.type = 'number'; - qty.min = 1; + qty.min = 0.001; + qty.step = 0.001; qty.value = 1; qty.className = 'form-control text-center p-1 rounded'; qty.style.width = '50px'; qty.style.margin = '0 2px'; - qty.title = 'Ilość'; + qty.title = 'Ilość / waga / objętość'; + + const unit = makeUnitSelect(); const plusBtn = document.createElement('button'); plusBtn.type = 'button'; @@ -170,20 +234,26 @@ document.addEventListener('DOMContentLoaded', function () { plusBtn.textContent = '+'; minusBtn.onclick = () => { - qty.value = Math.max(1, parseInt(qty.value) - 1); + qty.value = Math.max(0.001, readQuantity(qty) - 1); }; plusBtn.onclick = () => { - qty.value = parseInt(qty.value) + 1; + qty.value = readQuantity(qty) + 1; }; - qtyWrapper.append(minusBtn, qty, plusBtn); + qtyWrapper.append(minusBtn, qty, plusBtn, unit); 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 }); + const quantityValue = readQuantity(qty); + socket.emit('add_item', { + list_id: LIST_ID, + name: name, + quantity: Math.max(1, Math.round(quantityValue)), + quantity_value: quantityValue, + quantity_unit: unit.value + }); }; li.append(qtyWrapper, btn); @@ -266,12 +336,15 @@ document.addEventListener('DOMContentLoaded', function () { const qty = document.createElement('input'); qty.type = 'number'; - qty.min = 1; + qty.min = 0.001; + qty.step = 0.001; qty.value = 1; qty.className = 'form-control text-center p-1 rounded'; qty.style.width = '50px'; qty.style.margin = '0 2px'; - qty.title = 'Ilość'; + qty.title = 'Ilość / waga / objętość'; + + const unit = makeUnitSelect(); const plusBtn = document.createElement('button'); plusBtn.type = 'button'; @@ -279,24 +352,26 @@ document.addEventListener('DOMContentLoaded', function () { plusBtn.textContent = '+'; minusBtn.onclick = () => { - qty.value = Math.max(1, parseInt(qty.value) - 1); + qty.value = Math.max(0.001, readQuantity(qty) - 1); }; plusBtn.onclick = () => { - qty.value = parseInt(qty.value) + 1; + qty.value = readQuantity(qty) + 1; }; - qtyWrapper.append(minusBtn, qty, plusBtn); + qtyWrapper.append(minusBtn, qty, plusBtn, unit); li.appendChild(qtyWrapper); const addBtn = document.createElement('button'); addBtn.className = 'btn btn-sm btn-primary ms-4'; addBtn.textContent = '+'; addBtn.onclick = () => { - const quantity = parseInt(qty.value) || 1; + const quantityValue = readQuantity(qty); socket.emit('add_item', { list_id: LIST_ID, name: data.name, - quantity: quantity + quantity: Math.max(1, Math.round(quantityValue)), + quantity_value: quantityValue, + quantity_unit: unit.value }); }; li.appendChild(addBtn); diff --git a/shopping_app/static/js/receipt_section.js b/shopping_app/static/js/receipt_section.js index 78876b1..8988d7d 100644 --- a/shopping_app/static/js/receipt_section.js +++ b/shopping_app/static/js/receipt_section.js @@ -18,13 +18,21 @@ document.addEventListener("DOMContentLoaded", function () { localStorage.setItem(storageKey, state ? "true" : "false"); } + function getToggleLabel(shown) { + const isMobile = window.matchMedia("(max-width: 575.98px)").matches; + if (isMobile) { + return shown ? "Ukryj" : "Pokaż"; + } + return shown ? "Ukryj sekcję paragonów" : "Pokaż sekcję paragonów"; + } + function updateUI() { const shown = isShown(); toggleEl.classList.toggle("is-open", shown); toggleEl.setAttribute("aria-expanded", shown ? "true" : "false"); if (titleEl) { - titleEl.textContent = shown ? "Ukryj sekcję paragonów" : "Pokaż sekcję paragonów"; + titleEl.textContent = getToggleLabel(shown); } } @@ -50,5 +58,8 @@ document.addEventListener("DOMContentLoaded", function () { }); } + + window.addEventListener("resize", updateUI); + updateUI(); }); diff --git a/shopping_app/templates/list.html b/shopping_app/templates/list.html index 086497d..cd89b56 100644 --- a/shopping_app/templates/list.html +++ b/shopping_app/templates/list.html @@ -130,10 +130,13 @@ class="shopping-item-name text-white" data-item-id="{{ item.id }}" data-item-name={{ item.name|tojson }} - data-item-quantity="{{ item.quantity or 1 }}" + data-item-quantity="{{ item.quantity_value or item.quantity or 1 }}" + data-item-unit="{{ item.quantity_unit or 'szt' }}" {% if not list.is_archived %}data-item-menu-trigger="true"{% else %}disabled aria-disabled="true"{% endif %}>{{ item.name }} - {% if item.quantity and item.quantity > 1 %} - x{{ item.quantity }} + {% set qty_value = item.quantity_value or item.quantity or 1 %} + {% set qty_unit = item.quantity_unit or 'szt' %} + {% if qty_unit != 'szt' or qty_value != 1 %} + {% if qty_unit == 'szt' %}x{{ ('%g' % qty_value) }}{% else %}{{ ('%g' % qty_value) }} {{ qty_unit }}{% endif %} {% endif %} {% set info_parts = [] %} {% if item.note %}{% set _ = info_parts.append('[ ' ~ item.note ~ ' ]') %}{% endif %} @@ -145,8 +148,12 @@
{% if not is_share %} + + + %}onclick='openEditItemModal(event, {{ item.id }}, {{ item.name|tojson }}, {{ item.quantity_value or item.quantity or 1 }}, {{ (item.quantity_unit or "szt")|tojson }})' {% endif %}>✏️ + {% endif %} @@ -192,8 +199,18 @@
- - + +
+ + +
@@ -221,7 +238,18 @@ + placeholder="Ilość" min="0.001" step="0.001" value="1"> + + @@ -144,9 +156,9 @@ Strefa paragonów - Pokaż sekcję paragonów + Pokaż sekcję paragonów {{ receipts|length }} @@ -295,6 +307,44 @@ + + +