From 2c06be33d4b613856948cf06078b5f685694aa06 Mon Sep 17 00:00:00 2001 From: dadaloop82 Date: Wed, 29 Apr 2026 05:38:21 +0000 Subject: [PATCH] Improve use-flow UX and suppress redundant finished alerts --- README.md | 2 ++ api/index.php | 20 +++++++++++++-- assets/css/style.css | 24 ++++++++++++++++++ assets/js/app.js | 59 +++++++++++++++++++++++++++++--------------- translations/de.json | 1 + translations/en.json | 1 + translations/it.json | 1 + 7 files changed, 86 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 5eee1f2..972e158 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,8 @@ - Recipe and meal-plan labels now resolve at runtime from translations, preventing raw placeholders like `meal_types.*` and `meal_plan_types.*` from appearing in the UI. - Recipe generation now receives the selected app language (`it`/`en`/`de`) and enforces localized output in both streaming and non-streaming API flows. - Added missing shared error keys (`error.network`, `error.no_api_key`) across all language files to keep fallback/error toasts fully translated. +- "Use product" and "use recipe ingredient" location buttons now show a clear opened-package badge, so the default choice is visibly understandable. +- Explicit "used all / finished" actions are now treated as confirmed by the user and no longer create redundant finished-confirmation banners. ## ✨ Features diff --git a/api/index.php b/api/index.php index f415eae..0492f2a 100644 --- a/api/index.php +++ b/api/index.php @@ -943,13 +943,22 @@ function useFromInventory(PDO $db): void { $stmt->execute([$productId]); $allItems = $stmt->fetchAll(); $totalRemoved = 0; + $explicitFinish = ($notes !== 'Buttato'); foreach ($allItems as $item) { $totalRemoved += $item['quantity']; - $stmt = $db->prepare("UPDATE inventory SET quantity = 0, updated_at = CURRENT_TIMESTAMP WHERE id = ?"); - $stmt->execute([$item['id']]); $type = ($notes === 'Buttato') ? 'waste' : 'out'; $stmt = $db->prepare("INSERT INTO transactions (product_id, type, quantity, location, notes) VALUES (?, ?, ?, ?, ?)"); $stmt->execute([$productId, $type, $item['quantity'], $item['location'], $notes]); + + // User explicitly chose "use all/finished": do not keep qty=0 rows that + // would trigger a redundant "are you sure it's finished" banner. + if ($explicitFinish) { + $stmt = $db->prepare("DELETE FROM inventory WHERE id = ?"); + $stmt->execute([$item['id']]); + } else { + $stmt = $db->prepare("UPDATE inventory SET quantity = 0, updated_at = CURRENT_TIMESTAMP WHERE id = ?"); + $stmt->execute([$item['id']]); + } } echo json_encode(['success' => true, 'remaining' => 0, 'removed' => $totalRemoved]); return; @@ -1063,6 +1072,13 @@ function useFromInventory(PDO $db): void { $type = ($notes === 'Buttato') ? 'waste' : 'out'; $stmt = $db->prepare("INSERT INTO transactions (product_id, type, quantity, location, notes) VALUES (?, ?, ?, ?, ?)"); $stmt->execute([$productId, $type, $actualDeducted, $location, $notes]); + + // User explicitly chose "use all/finished": remove this row now instead of + // leaving quantity=0 pending confirmation. + if ($useAll && $notes !== 'Buttato' && $newQty <= 0) { + $stmt = $db->prepare("DELETE FROM inventory WHERE id = ?"); + $stmt->execute([$existing['id']]); + } $remaining = $newQty; diff --git a/assets/css/style.css b/assets/css/style.css index 60f57dd..74cb5d9 100644 --- a/assets/css/style.css +++ b/assets/css/style.css @@ -991,6 +991,30 @@ body { transform: scale(0.97); } +.loc-btn-opened { + border-color: #f59e0b; + box-shadow: 0 0 0 2px rgba(245, 158, 11, 0.15); +} + +.loc-opened-badge { + display: inline-block; + margin-left: 6px; + padding: 2px 7px; + border-radius: 999px; + font-size: 0.72rem; + font-weight: 800; + letter-spacing: 0.02em; + color: #7a4b00; + background: #fde68a; + border: 1px solid #f59e0b; +} + +.loc-btn.active .loc-opened-badge { + color: #7a4b00; + background: #fde68a; + border-color: #f59e0b; +} + /* ===== QUANTITY CONTROL ===== */ .qty-control { display: flex; diff --git a/assets/js/app.js b/assets/js/app.js index 1c6ace2..010a218 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -5583,8 +5583,16 @@ let _useNormalUnit = 'pz'; // unit when not in conf mode function _renderUseExpiryHint(items) { const hintEl = document.getElementById('use-expiry-hint'); - // Filtra solo item con scadenza e quantitΓ  > 0 - const withExpiry = items.filter(i => i.expiry_date && parseFloat(i.quantity) > 0); + // Parse YYYY-MM-DD as local noon to avoid timezone edge cases on some engines. + const parseLocalExpiryDate = (dateStr) => { + if (!dateStr) return null; + const m = String(dateStr).match(/^(\d{4})-(\d{2})-(\d{2})/); + if (!m) return null; + return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]), 12, 0, 0, 0); + }; + + // Ignore tiny residual quantities to avoid misleading hints on near-zero leftovers. + const withExpiry = items.filter(i => i.expiry_date && parseFloat(i.quantity) > 0.01); // Serve almeno 2 item con scadenze diverse (o locazioni diverse con scadenze) if (withExpiry.length < 2) { hintEl.style.display = 'none'; return; } @@ -5597,11 +5605,16 @@ function _renderUseExpiryHint(items) { if (uniqueDates.size < 2 && uniqueLocs.size < 2) { hintEl.style.display = 'none'; return; } // Trova il piΓΉ vicino alla scadenza - withExpiry.sort((a, b) => new Date(a.expiry_date) - new Date(b.expiry_date)); + withExpiry.sort((a, b) => { + const da = parseLocalExpiryDate(a.expiry_date); + const db = parseLocalExpiryDate(b.expiry_date); + return (da ? da.getTime() : Infinity) - (db ? db.getTime() : Infinity); + }); const soonest = withExpiry[0]; + const expDate = parseLocalExpiryDate(soonest.expiry_date); + if (!expDate || Number.isNaN(expDate.getTime())) { hintEl.style.display = 'none'; return; } const today = new Date(); today.setHours(0,0,0,0); - const expDate = new Date(soonest.expiry_date); const diffDays = Math.round((expDate - today) / 86400000); const locInfo = LOCATIONS[soonest.location] || { icon: 'πŸ“¦', label: soonest.location }; @@ -5621,6 +5634,18 @@ function _renderUseExpiryHint(items) { hintEl.style.display = 'block'; } +function _isOpenedInventoryItem(item) { + const q = parseFloat(item.quantity); + const dq = parseFloat(item.default_quantity) || 0; + if (item.unit === 'conf' && dq > 0) return q !== Math.floor(q); + if (dq > 0) return Math.abs(q - Math.round(q / dq) * dq) > dq * 0.02; + return false; +} + +function _locationHasOpenedPackage(items, location) { + return items.some(i => i.location === location && _isOpenedInventoryItem(i)); +} + async function loadUseInventoryInfo() { try { const data = await api('inventory_list'); @@ -5641,13 +5666,7 @@ async function loadUseInventoryInfo() { // ───────────────────────────────────────────────────────────────── // Auto-select the location with an opened package first (use from opened before sealed) - const openedItem = items.find(i => { - const q = parseFloat(i.quantity); - const dq = parseFloat(i.default_quantity) || 0; - if (i.unit === 'conf' && dq > 0) return q !== Math.floor(q); - if (dq > 0) return Math.abs(q - Math.round(q / dq) * dq) > dq * 0.02; - return false; - }); + const openedItem = items.find(_isOpenedInventoryItem); const firstLoc = openedItem ? openedItem.location : items[0].location; // Build location buttons only for locations where the product exists @@ -5666,7 +5685,10 @@ async function loadUseInventoryInfo() { const locQty = locItems.reduce((s, i) => s + parseFloat(i.quantity), 0); const u = locItems[0].unit || 'pz'; const qtyLabel = formatQuantity(locQty, u, locItems[0].default_quantity, locItems[0].package_unit); - return ``; + const openedBadge = _locationHasOpenedPackage(items, loc) + ? ` πŸ”“ ${t('use.opened_badge')}` + : ''; + return ``; }).join(''); if (prefLoc && productLocations.includes(prefLoc) && productLocations.length > 1) { @@ -8953,13 +8975,7 @@ async function useRecipeIngredient(idx, productId, location, qtyNumber, btn, rec const isConf = unit === 'conf' && pkgSize > 0 && pkgUnit; // Find opened package location - const openedItem = items.find(i => { - const q = parseFloat(i.quantity); - const dq = parseFloat(i.default_quantity) || 0; - if (i.unit === 'conf' && dq > 0) return q !== Math.floor(q); - if (dq > 0) return Math.abs(q - Math.round(q / dq) * dq) > dq * 0.02; - return false; - }); + const openedItem = items.find(_isOpenedInventoryItem); const defaultLoc = openedItem ? openedItem.location : (items.find(i => i.location === location) ? location : items[0].location); // Build location buttons @@ -8969,7 +8985,10 @@ async function useRecipeIngredient(idx, productId, location, qtyNumber, btn, rec const locItems = items.filter(i => i.location === loc); const locQty = locItems.reduce((s, i) => s + parseFloat(i.quantity), 0); const qtyLabel = formatQuantity(locQty, unit, pkgSize, pkgUnit); - return ``; + const openedBadge = _locationHasOpenedPackage(items, loc) + ? ` πŸ”“ ${t('use.opened_badge')}` + : ''; + return ``; }).join(''); // Build quantity controls diff --git a/translations/de.json b/translations/de.json index 4122e56..f39bd61 100644 --- a/translations/de.json +++ b/translations/de.json @@ -222,6 +222,7 @@ "use_all": "πŸ—‘οΈ ALLES verwendet / Aufgebraucht", "submit": "πŸ“€ Diese Menge verwenden", "available": "πŸ“¦ VerfΓΌgbar:", + "opened_badge": "GEOEFFNET", "not_in_inventory": "⚠️ Produkt nicht im Bestand.", "expiry_warning": "⚠️ Verwende zuerst die{loc}, die am {date} ablΓ€uft β€” {when}!", "throw_title": "πŸ—‘οΈ Produkt entsorgen", diff --git a/translations/en.json b/translations/en.json index 655487a..a39e2fb 100644 --- a/translations/en.json +++ b/translations/en.json @@ -222,6 +222,7 @@ "use_all": "πŸ—‘οΈ Used ALL / Finished", "submit": "πŸ“€ Use this quantity", "available": "πŸ“¦ Available:", + "opened_badge": "OPENED", "not_in_inventory": "⚠️ Product not in inventory.", "expiry_warning": "⚠️ Use first the one{loc} that expires on {date} β€” {when}!", "throw_title": "πŸ—‘οΈ Discard Product", diff --git a/translations/it.json b/translations/it.json index d2d8f4e..8b5598d 100644 --- a/translations/it.json +++ b/translations/it.json @@ -222,6 +222,7 @@ "use_all": "πŸ—‘οΈ Usato TUTTO / Finito", "submit": "πŸ“€ Usa questa quantitΓ ", "available": "πŸ“¦ Disponibile:", + "opened_badge": "APERTO", "not_in_inventory": "⚠️ Prodotto non presente nell'inventario.", "expiry_warning": "⚠️ Usa prima quella{loc} che scade il {date} β€” {when}!", "throw_title": "πŸ—‘οΈ Butta Prodotto",