Merge pull request 'Jednostki' (#16) from jednostki into master

Reviewed-on: #16
This commit was merged in pull request #16.
This commit is contained in:
gru
2026-06-08 13:24:59 +02:00
13 changed files with 489 additions and 129 deletions
+90
View File
@@ -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
+4
View File
@@ -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)
+36 -21
View File
@@ -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,
+34 -8
View File
@@ -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;
}
@@ -2092,7 +2111,11 @@ body:not(.sorting-active) .drag-handle {
.endpoint-list .shopping-entry-card .shopping-product-input-group > .form-control,
.endpoint-list_share .shopping-entry-card .shopping-product-input-group > .form-control,
.endpoint-shared_list .shopping-entry-card .shopping-product-input-group > .form-control,
.endpoint-view_list .shopping-entry-card .shopping-product-input-group > .form-control {
.endpoint-view_list .shopping-entry-card .shopping-product-input-group > .form-control,
.endpoint-list .shopping-entry-card .shopping-product-input-group > .form-select,
.endpoint-list_share .shopping-entry-card .shopping-product-input-group > .form-select,
.endpoint-shared_list .shopping-entry-card .shopping-product-input-group > .form-select,
.endpoint-view_list .shopping-entry-card .shopping-product-input-group > .form-select {
border-color: rgba(25, 135, 84, 0.55) !important;
background: rgba(17, 24, 39, 0.95) !important;
}
@@ -2104,10 +2127,14 @@ body:not(.sorting-active) .drag-handle {
color: rgba(255, 255, 255, 0.62);
}
.endpoint-list .shopping-entry-card .shopping-product-input-group > .shopping-product-name-input:focus,
.endpoint-list_share .shopping-entry-card .shopping-product-input-group > .shopping-product-name-input:focus,
.endpoint-shared_list .shopping-entry-card .shopping-product-input-group > .shopping-product-name-input:focus,
.endpoint-view_list .shopping-entry-card .shopping-product-input-group > .shopping-product-name-input:focus {
.endpoint-list .shopping-entry-card .shopping-product-input-group > .form-control:focus,
.endpoint-list_share .shopping-entry-card .shopping-product-input-group > .form-control:focus,
.endpoint-shared_list .shopping-entry-card .shopping-product-input-group > .form-control:focus,
.endpoint-view_list .shopping-entry-card .shopping-product-input-group > .form-control:focus,
.endpoint-list .shopping-entry-card .shopping-product-input-group > .form-select:focus,
.endpoint-list_share .shopping-entry-card .shopping-product-input-group > .form-select:focus,
.endpoint-shared_list .shopping-entry-card .shopping-product-input-group > .form-select:focus,
.endpoint-view_list .shopping-entry-card .shopping-product-input-group > .form-select:focus {
box-shadow: inset 0 0 0 1px rgba(25, 135, 84, 0.25), 0 0 0 .2rem rgba(25, 135, 84, 0.18);
}
@@ -2323,4 +2350,3 @@ body:not(.sorting-active) .drag-handle {
#desktopItemMenu[hidden] {
display: none !important;
}
+7 -1
View File
@@ -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 {
+43 -17
View File
@@ -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;
+52 -26
View File
@@ -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();
@@ -389,8 +411,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
? `<span class="badge rounded-pill bg-secondary">x${quantity}</span>`
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)
? `<span class="badge rounded-pill bg-secondary">${escapeHtml(quantityText)}</span>`
: '';
const canEditListItem = !isShare;
@@ -424,7 +449,8 @@ function renderItem(item, isShare = window.IS_SHARE, optionsOrShowEditOnly = fal
class="shopping-item-name text-white"
data-item-id="${item.id}"
data-item-name=${JSON.stringify(rawName)}
data-item-quantity="${quantity}"
data-item-quantity="${quantityValue}"
data-item-unit="${quantityUnit}"
${isArchived ? 'disabled aria-disabled="true"' : 'data-item-menu-trigger="true"'}>${safeName}</button>`
: `<span id="name-${item.id}" class="shopping-item-name text-white">${safeName}</span>`;
let actionButtons = '';
@@ -436,7 +462,7 @@ function renderItem(item, isShare = window.IS_SHARE, optionsOrShowEditOnly = fal
actionButtons += `
${dragHandleButton}
<button type="button" class="${iconBtn}" ${isArchived ? 'disabled' : `onclick='openEditItemModal(event, ${item.id}, ${JSON.stringify(String(item.name || ''))}, ${quantity})'`}></button>
<button type="button" class="${iconBtn}" ${isArchived ? 'disabled' : `onclick='openEditItemModal(event, ${item.id}, ${JSON.stringify(String(item.name || ''))}, ${quantityValue}, ${JSON.stringify(quantityUnit)})'`}></button>
<button type="button" class="${iconBtn}" ${isArchived ? 'disabled' : `onclick="deleteItem(${item.id})"`}>🗑</button>`;
}
@@ -451,7 +477,7 @@ function renderItem(item, isShare = window.IS_SHARE, optionsOrShowEditOnly = fal
if (temporaryShareUndo) {
actionButtons += `
<button type="button" class="${iconBtn} shopping-action-btn--countdown" disabled data-countdown-for="${item.id}">${countdownSeconds}s</button>
<button type="button" class="${iconBtn}" ${isArchived ? 'disabled' : `onclick='openEditItemModal(event, ${item.id}, ${nameForEdit}, ${quantity})'`}></button>
<button type="button" class="${iconBtn}" ${isArchived ? 'disabled' : `onclick='openEditItemModal(event, ${item.id}, ${nameForEdit}, ${quantityValue}, ${JSON.stringify(quantityUnit)})'`}></button>
<button type="button" class="${iconBtn}" ${isArchived ? 'disabled' : `onclick="deleteItem(${item.id})"`}>🗑</button>`;
} else if (canShowShareActions || (!isShare && isOwner)) {
actionButtons += `
+19
View File
@@ -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();
});
});
+4 -1
View File
@@ -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();
+97 -22
View File
@@ -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);
+12 -1
View File
@@ -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();
});
+33 -28
View File
@@ -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 }}</button>
{% if item.quantity and item.quantity > 1 %}
<span class="badge rounded-pill bg-secondary">x{{ item.quantity }}</span>
{% 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 %}
<span class="badge rounded-pill bg-secondary">{% if qty_unit == 'szt' %}x{{ ('%g' % qty_value) }}{% else %}{{ ('%g' % qty_value) }} {{ qty_unit }}{% endif %}</span>
{% endif %}
{% set info_parts = [] %}
{% if item.note %}{% set _ = info_parts.append('<span class="text-danger">[ <b>' ~ item.note ~ '</b> ]</span>') %}{% endif %}
@@ -146,7 +149,8 @@
<div class="list-item-actions shopping-item-actions" role="group">
{% if not is_share %}
<button type="button" class="btn btn-outline-light btn-sm shopping-action-btn" {% if list.is_archived %}disabled{% else
%}onclick='openEditItemModal(event, {{ item.id }}, {{ item.name|tojson }}, {{ item.quantity or 1 }})' {% endif %}>✏️</button>
%}onclick='openEditItemModal(event, {{ item.id }}, {{ item.name|tojson }}, {{ item.quantity_value or item.quantity or 1 }}, {{ (item.quantity_unit or "szt")|tojson }})' {% endif %}>✏️</button>
<button type="button" class="btn btn-outline-light btn-sm shopping-action-btn" {% if list.is_archived %}disabled{% else
%}onclick="deleteItem({{ item.id }})" {% endif %}>🗑️</button>
{% endif %}
@@ -192,8 +196,18 @@
<input type="text" id="editItemName" class="form-control bg-dark text-white border-secondary" maxlength="255" required>
</div>
<div>
<label for="editItemQuantity" class="form-label">Ilość</label>
<input type="number" id="editItemQuantity" class="form-control bg-dark text-white border-secondary" min="1" step="1" required>
<label for="editItemQuantity" class="form-label">Ilość / waga / objętość</label>
<div class="input-group">
<input type="number" id="editItemQuantity" class="form-control bg-dark text-white border-secondary" min="0.001" step="0.001" required>
<select id="editItemUnit" class="form-select bg-dark text-white border-secondary" style="max-width: 110px;">
<option value="szt">szt</option>
<option value="kg">kg</option>
<option value="g">g</option>
<option value="l">l</option>
<option value="ml">ml</option>
<option value="opak.">opak.</option>
</select>
</div>
</div>
</div>
@@ -221,7 +235,18 @@
<input type="number" id="newQuantity" name="quantity"
class="form-control bg-dark text-white border-secondary shopping-qty-input"
placeholder="Ilość" min="1" value="1">
placeholder="Ilość" min="0.001" step="0.001" value="1">
<select id="newUnit" name="quantity_unit"
class="form-select bg-dark text-white border-secondary shopping-qty-unit"
style="max-width: 105px;">
<option value="szt">szt</option>
<option value="kg">kg</option>
<option value="g">g</option>
<option value="l">l</option>
<option value="ml">ml</option>
<option value="opak.">opak.</option>
</select>
<button type="button"
class="btn btn-outline-success share-submit-btn shopping-compact-submit"
@@ -555,28 +580,8 @@
<script src="{{ static_asset_url('static_bp.serve_js', 'desktop_item_menu.js') }}"></script>
<script>
setupList({{ list.id }}, '{{ current_user.username if current_user.is_authenticated else 'Gość' }}');
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;
editItem(itemId, itemName, itemQuantity);
const modalEl = document.getElementById('editItemModal');
const modal = bootstrap.Modal.getInstance(modalEl);
if (modal) {
modal.hide();
}
});
});
</script>
<script src="{{ static_asset_url('static_bp.serve_js', 'list_item_form.js') }}"></script>
{% endblock %}
{% endblock %}
+58 -4
View File
@@ -53,8 +53,10 @@
<div class="shopping-item-head">
<div class="shopping-item-text">
<span id="name-{{ item.id }}" class="shopping-item-name text-white">{{ item.name }}</span>
{% if item.quantity and item.quantity > 1 %}
<span class="badge rounded-pill bg-secondary">x{{ item.quantity }}</span>
{% 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 %}
<span class="badge rounded-pill bg-secondary">{% if qty_unit == 'szt' %}x{{ ('%g' % qty_value) }}{% else %}{{ ('%g' % qty_value) }} {{ qty_unit }}{% endif %}</span>
{% endif %}
{% set info_parts = [] %}
{% if item.note %}{% set _ = info_parts.append('<span class="text-danger">[ <b>' ~ item.note ~ '</b> ]</span>') %}{% endif %}
@@ -101,7 +103,17 @@
<input id="newItem" class="form-control bg-dark text-white border-secondary shopping-product-name-input" placeholder="Dodaj produkt" {% if
not current_user.is_authenticated %}disabled{% endif %}>
<input id="newQuantity" type="number" class="form-control bg-dark text-white border-secondary shopping-qty-input" placeholder="Ilość"
min="1" value="1" {% if not current_user.is_authenticated %}disabled{% endif %}>
min="0.001" step="0.001" value="1" {% if not current_user.is_authenticated %}disabled{% endif %}>
<select id="newUnit" name="quantity_unit"
class="form-select bg-dark text-white border-secondary shopping-qty-unit"
style="max-width: 105px;" {% if not current_user.is_authenticated %}disabled{% endif %}>
<option value="szt">szt</option>
<option value="kg">kg</option>
<option value="g">g</option>
<option value="l">l</option>
<option value="ml">ml</option>
<option value="opak.">opak.</option>
</select>
<button onclick="addItem({{ list.id }})" class="btn btn-outline-success share-submit-btn shopping-compact-submit" {% if not
current_user.is_authenticated %}disabled{% endif %}><span class="shopping-btn-icon" aria-hidden="true"></span><span class="shopping-btn-label">Dodaj</span></button>
</div>
@@ -144,9 +156,9 @@
<span class="receipt-disclosure__icon" aria-hidden="true">🧾</span>
<span class="receipt-disclosure__text">
<span class="receipt-disclosure__eyebrow">Strefa paragonów</span>
<span class="receipt-disclosure__title">Pokaż sekcję paragonów</span>
</span>
<span class="receipt-disclosure__meta">
<span class="receipt-disclosure__title">Pokaż sekcję paragonów</span>
<span class="receipt-disclosure__count">{{ receipts|length }}</span>
<span class="receipt-disclosure__chevron" aria-hidden="true"></span>
</span>
@@ -295,6 +307,47 @@
</div>
</div>
<div class="modal fade" id="editItemModal" tabindex="-1" aria-labelledby="editItemModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content bg-dark text-white">
<form id="editItemForm">
<div class="modal-header">
<h5 class="modal-title" id="editItemModalLabel">Edytuj produkt</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Zamknij"></button>
</div>
<div class="modal-body">
<input type="hidden" id="editItemId">
<div class="mb-3">
<label for="editItemName" class="form-label">Nazwa produktu</label>
<input type="text" id="editItemName" class="form-control bg-dark text-white border-secondary" maxlength="255" required>
</div>
<div>
<label for="editItemQuantity" class="form-label">Ilość / waga / objętość</label>
<div class="input-group">
<input type="number" id="editItemQuantity" class="form-control bg-dark text-white border-secondary" min="0.001" step="0.001" required>
<select id="editItemUnit" class="form-select bg-dark text-white border-secondary" style="max-width: 110px;">
<option value="szt">szt</option>
<option value="kg">kg</option>
<option value="g">g</option>
<option value="l">l</option>
<option value="ml">ml</option>
<option value="opak.">opak.</option>
</select>
</div>
</div>
</div>
<div class="modal-footer justify-content-end">
<div class="btn-group" role="group">
<button type="button" class="btn btn-sm btn-outline-light" data-bs-dismiss="modal">❌ Anuluj</button>
<button type="submit" class="btn btn-sm btn-outline-light"><span class="shopping-btn-icon" aria-hidden="true">💾</span><span class="shopping-btn-label">Zapisz</span></button>
</div>
</div>
</form>
</div>
</div>
</div>
<!-- Modal notatki -->
<div class="modal fade" id="noteModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
@@ -341,6 +394,7 @@
<script>
setupList({{ list.id }}, '{{ current_user.username if current_user.is_authenticated else 'Gość' }}');
</script>
<script src="{{ static_asset_url('static_bp.serve_js', 'list_item_form.js') }}"></script>
{% endblock %}
{% endblock %}