From c814d99d1f83e5705751c4bac145395027cedb11 Mon Sep 17 00:00:00 2001 From: dadaloop82 Date: Sun, 3 May 2026 13:12:35 +0000 Subject: [PATCH] feat: smart use-all context, scale baseline reset, freezer-ok alert suppression, conf qty fix, low-stock finish button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - submitUseAll() now detects opened packages: if current location has an opened pack, finishes only that; if exactly one opened pack exists elsewhere, uses it automatically; multiple opened packs → disambiguation modal - quickUse() resets scale baseline on page open so stale weight doesn't immediately trigger auto-fill - Expired alerts (dashboard + banner) now filter out freezer items within their safety window (level='ok') - Review banner: conf unit quantity displayed as sub-unit total (e.g. 800g) instead of raw pack count; high-qty threshold evaluated on sub-unit volume to prevent '400 confezioni' nonsense - Low-stock review banner gains 'È finito tutto' button → new bannerFinishAll() handler - New _submitUseAllAt() helper and _showUseAllDisambiguation() modal - New translation keys: toast_opened_finished, disambiguation_hint, disambiguation_all, banner_review_action_finish (it/en/de) --- assets/js/app.js | 189 ++++++++++++++++++++++++++++++++++++++++--- translations/de.json | 6 +- translations/en.json | 6 +- translations/it.json | 6 +- 4 files changed, 192 insertions(+), 15 deletions(-) diff --git a/assets/js/app.js b/assets/js/app.js index 50aeaff..2a840f4 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -2477,12 +2477,16 @@ async function loadDashboard() { expiringSection.style.display = 'none'; } - // Expired items + // Expired items — items in the freezer that are still within the safety window are hidden const expiredSection = document.getElementById('alert-expired'); const expiredList = document.getElementById('expired-list'); - if (statsData.expired && statsData.expired.length > 0) { + const visibleExpired = (statsData.expired || []).filter(item => { + const days = Math.abs(daysUntilExpiry(item.expiry_date)); + return getExpiredSafety(item, days).level !== 'ok'; + }); + if (visibleExpired.length > 0) { expiredSection.style.display = 'block'; - expiredList.innerHTML = statsData.expired.map(item => { + expiredList.innerHTML = visibleExpired.map(item => { const days = Math.abs(daysUntilExpiry(item.expiry_date)); let daysText; if (days === 0) daysText = t('expiry.expired_today'); @@ -2722,6 +2726,8 @@ async function loadBannerAlerts() { } if (daysExpired === null) return; // not expired by any measure + // Skip items the freezer bonus still considers safe — no need to alarm the user + if (getExpiredSafety(item, daysExpired).level === 'ok') return; _bannerQueue.push({ type: 'expired', data: { ...item, days_expired: daysExpired } }); }); @@ -2741,8 +2747,25 @@ async function loadBannerAlerts() { if (confirmed[item.id]) return; const t_ = QTY_THRESHOLDS[item.unit] || QTY_THRESHOLDS['pz']; const qty = parseFloat(item.quantity); - const isLow = !isNaN(qty) && qty > 0 && qty < t_.min; - const isHigh = !isNaN(qty) && qty > t_.max; + let isLow = !isNaN(qty) && qty > 0 && qty < t_.min; + let isHigh = !isNaN(qty) && qty > t_.max; + + // For conf unit: evaluate thresholds on total sub-unit volume when possible, + // not on raw package count. "400 conf" with no package size is uninterpretable + // (could be grams entered with the wrong unit) — skip the high check. + if (item.unit === 'conf') { + const pkgSize = parseFloat(item.default_quantity); + if (pkgSize > 0 && item.package_unit) { + const totalSub = qty * pkgSize; + const subTh = QTY_THRESHOLDS[item.package_unit] || QTY_THRESHOLDS['pz']; + isLow = totalSub > 0 && totalSub < subTh.min; + isHigh = totalSub > subTh.max; + } else { + // No package size known — can't judge quantity; suppress high-qty noise + isHigh = false; + } + } + const suspDq = isSuspiciousDefaultQty(item.default_quantity, item.unit, item.package_unit); if (!isLow && !isHigh && !suspDq) return; @@ -2758,7 +2781,7 @@ async function loadBannerAlerts() { if (suspDq && !isLow && !isHigh) warning = '📦 Conf. sospetta'; else if (isLow) warning = '⬇️ Troppo poco'; else warning = '⬆️ Troppo'; - _bannerQueue.push({ type: 'review', data: { ...item, warning } }); + _bannerQueue.push({ type: 'review', data: { ...item, warning, _isLow: isLow } }); }); // 4. Consumption predictions that don't match actual quantity @@ -2893,17 +2916,25 @@ function renderBannerItem() { } else if (entry.type === 'review') { const item = entry.data; - const qtyDisplay = formatQuantity(item.quantity, item.unit, item.default_quantity, item.package_unit); + // For conf unit with known package size, display the sub-unit total (e.g., 800g) + // instead of a raw conf count that could be confused with "N confezioni". + let qtyDisplay; + if (item.unit === 'conf' && parseFloat(item.default_quantity) > 0 && item.package_unit) { + const totalSub = Math.round(parseFloat(item.quantity) * parseFloat(item.default_quantity)); + qtyDisplay = `${totalSub} ${item.package_unit}`; + } else { + qtyDisplay = formatQuantity(item.quantity, item.unit, item.default_quantity, item.package_unit); + } const suspDq = isSuspiciousDefaultQty(item.default_quantity, item.unit, item.package_unit); - const suspQty = isSuspiciousQty(item.quantity, item.unit); + const isLow = !!item._isLow; // set when banner item was built const t_ = QTY_THRESHOLDS[item.unit] || QTY_THRESHOLDS['pz']; banner.className = 'alert-banner'; iconEl.textContent = '⚠️'; let titleText, detailText; - if (suspDq && !suspQty) { + if (suspDq && !isLow) { titleText = `${t('dashboard.banner_review_unusual_pkg_title')}: ${item.name}${item.brand ? ' (' + item.brand + ')' : ''}`; detailText = t('dashboard.banner_review_unusual_pkg_detail', { qty: item.default_quantity, unit: item.package_unit }); - } else if (parseFloat(item.quantity) < t_.min) { + } else if (isLow) { titleText = `${t('dashboard.banner_review_low_qty_title')}: ${item.name}${item.brand ? ' (' + item.brand + ')' : ''}`; detailText = t('dashboard.banner_review_low_qty_detail', { qty: qtyDisplay }); } else { @@ -2913,6 +2944,9 @@ function renderBannerItem() { titleEl.textContent = titleText; detailEl.textContent = detailText; let btns = ``; + if (isLow) { + btns += ``; + } btns += ``; if (hasScale) { btns += ``; @@ -3103,6 +3137,25 @@ function bannerThrowAway() { dismissBannerItem(); } +function bannerFinishAll() { + const entry = _bannerQueue[_bannerIndex]; + if (!entry) return; + const item = entry.data; + dismissBannerItem(); + api('inventory_use', {}, 'POST', { + product_id: item.product_id, + use_all: true, + location: '__all__', + }).then(res => { + if (res.success) { + showToast(`📤 ${item.name} terminato!`, 'success'); + showLowStockBringPrompt(res, () => loadDashboard()); + } else { + showToast(res.error || 'Errore', 'error'); + } + }).catch(() => showToast(t('error.connection'), 'error')); +} + function editBannerExpiry() { const entry = _bannerQueue[_bannerIndex]; if (!entry || (entry.type !== 'expired' && entry.type !== 'expiring')) return; @@ -3649,6 +3702,17 @@ async function quickUse(productId, location) { }); renderUsePreview(); + + // Reset scale state so the stale weight already on the scale doesn't + // immediately trigger an auto-fill. Only a weight *change* (≥10 g) after + // the page opens should be treated as a new product being placed. + _cancelScaleAutoConfirm(false); // stops timers, clears _scaleStabilityVal & _scaleLastConfirmedGrams + if (_scaleLatestWeight) { + const _baselineG = _scaleToGrams(parseFloat(_scaleLatestWeight.value), _scaleLatestWeight.unit); + if (_baselineG !== null && _baselineG >= 10) _scaleLastConfirmedGrams = _baselineG; + _scaleLatestWeight = null; // prevent immediate call inside loadUseInventoryInfo + } + loadUseInventoryInfo(); showLoading(false); showPage('use'); @@ -5942,6 +6006,7 @@ function renderUsePreview() { // Conf-mode tracking for USE form let _useConfMode = null; // null = normal, { packageSize, packageUnit, totalSub, unit } = conf mode active let _useNormalUnit = 'pz'; // unit when not in conf mode +let _useCurrentItems = []; // cached inventory items for the current product on the use page /** * Mostra un suggerimento giallo sotto le info inventario quando ci sono più @@ -6018,6 +6083,7 @@ async function loadUseInventoryInfo() { try { const data = await api('inventory_list'); const items = (data.inventory || []).filter(i => i.product_id == currentProduct.id); + _useCurrentItems = items; // cache for submitUseAll context detection const infoEl = document.getElementById('use-inventory-info'); const unitSwitch = document.getElementById('use-unit-switch'); @@ -6573,14 +6639,47 @@ async function confirmMoveAfterUse(productId, fromLoc, toLoc, openedId) { async function submitUseAll() { showLoading(true); try { + const currentLoc = document.getElementById('use-location').value; + const items = _useCurrentItems.filter(i => parseFloat(i.quantity) > 0); + + const openedAtCurrentLoc = items.find(i => i.location === currentLoc && _isOpenedInventoryItem(i)); + const allOpened = items.filter(_isOpenedInventoryItem); + + let useLocation; + + if (openedAtCurrentLoc) { + // Opened package at the currently selected location → finish only the opened item. + // The PHP backend fetches fractional (opened) rows first, so use_all on a specific + // location will clear the opened row and leave sealed packages untouched. + useLocation = currentLoc; + } else if (allOpened.length === 1) { + // One opened package somewhere else → almost certainly this is what the user means + useLocation = allOpened[0].location; + } else if (allOpened.length > 1) { + // Multiple opened packages at different locations → ask the user + showLoading(false); + _showUseAllDisambiguation(allOpened, items); + return; + } else { + // No opened packages anywhere → finish everything (original behaviour) + useLocation = '__all__'; + } + + const isOpenedFinish = useLocation !== '__all__' && items.some( + i => i.location === useLocation && _isOpenedInventoryItem(i) + ); + const result = await api('inventory_use', {}, 'POST', { product_id: currentProduct.id, use_all: true, - location: '__all__', + location: useLocation, }); showLoading(false); if (result.success) { - showToast(`📤 ${currentProduct.name} terminato!`, 'success'); + const toastMsg = isOpenedFinish + ? `🔓 ${t('use.toast_opened_finished').replace('{name}', currentProduct.name)}` + : `📤 ${currentProduct.name} terminato!`; + showToast(toastMsg, 'success'); if (result.added_to_bring) { setTimeout(() => showToast(t('use.toast_bring'), 'info'), 1500); } @@ -6595,6 +6694,72 @@ async function submitUseAll() { } } +/** + * Show a modal asking which opened package to mark as finished. + * Called when multiple opened packages exist across different locations. + */ +function _showUseAllDisambiguation(openedItems, allItems) { + const contentEl = document.getElementById('modal-content'); + const locButtons = openedItems.map(item => { + const locInfo = LOCATIONS[item.location] || { icon: '📦', label: item.location }; + const qtyStr = formatQuantity(parseFloat(item.quantity), item.unit, item.default_quantity, item.package_unit); + return ``; + }).join(''); + + // Option to finish everything + const totalQty = allItems.reduce((s, i) => s + parseFloat(i.quantity), 0); + const unit = allItems[0]?.unit || 'pz'; + const defaultQty = allItems[0]?.default_quantity; + const pkgUnit = allItems[0]?.package_unit; + const totalStr = formatQuantity(totalQty, unit, defaultQty, pkgUnit); + + contentEl.innerHTML = ` + +

${t('use.disambiguation_hint')}

+ ${locButtons} + + `; + document.getElementById('modal-overlay').style.display = 'flex'; +} + +async function _submitUseAllAt(location, isOpenedOnly) { + showLoading(true); + try { + const result = await api('inventory_use', {}, 'POST', { + product_id: currentProduct.id, + use_all: true, + location, + }); + showLoading(false); + if (result.success) { + const toastMsg = isOpenedOnly + ? `🔓 ${t('use.toast_opened_finished').replace('{name}', currentProduct.name)}` + : `📤 ${currentProduct.name} terminato!`; + showToast(toastMsg, 'success'); + if (result.added_to_bring) { + setTimeout(() => showToast(t('use.toast_bring'), 'info'), 1500); + } + showLowStockBringPrompt(result, () => showPage('dashboard')); + } else { + showToast(result.error || 'Errore', 'error'); + } + } catch (err) { + showLoading(false); + showToast(t('error.connection'), 'error'); + } +} + async function submitUse(e) { e.preventDefault(); if (_useSubmitting) return; // prevent double-submit from scale auto-confirm diff --git a/translations/de.json b/translations/de.json index 11b66fd..05ae61a 100644 --- a/translations/de.json +++ b/translations/de.json @@ -87,6 +87,7 @@ "quick_recipe": "🍳 Schnelles Rezept mit ablaufenden Produkten", "banner_review_title": "Ungewöhnliche Menge", "banner_review_action_ok": "Ist korrekt", + "banner_review_action_finish": "🗑️ Alles aufgebraucht", "banner_review_action_edit": "Korrigieren", "banner_review_action_weigh": "Wiegen", "banner_review_dismiss": "Ignorieren", @@ -238,7 +239,10 @@ "when_tomorrow": "läuft morgen ab", "when_days": "läuft in {n} Tagen ab", "toast_used": "📤 {qty} von {name} verwendet", - "toast_bring": "🛒 Produkt aufgebraucht → zu Bring! hinzugefügt" + "toast_bring": "🛒 Produkt aufgebraucht → zu Bring! hinzugefügt", + "toast_opened_finished": "🔓 Geöffnete Packung von {name} aufgebraucht!", + "disambiguation_hint": "Was meinst du mit \"alles aufgebraucht\"?", + "disambiguation_all": "🗑️ ALLES aufgebraucht ({qty})" }, "product": { "title_new": "Neues Produkt", diff --git a/translations/en.json b/translations/en.json index f05e281..009d2ea 100644 --- a/translations/en.json +++ b/translations/en.json @@ -87,6 +87,7 @@ "quick_recipe": "🍳 Quick recipe with expiring products", "banner_review_title": "Anomalous quantity", "banner_review_action_ok": "It's correct", + "banner_review_action_finish": "🗑️ All gone", "banner_review_action_edit": "Correct", "banner_review_action_weigh": "Weigh", "banner_review_dismiss": "Dismiss", @@ -237,7 +238,10 @@ "when_tomorrow": "expires tomorrow", "when_days": "expires in {n} days", "toast_used": "📤 Used {qty} of {name}", - "toast_bring": "🛒 Product finished → added to Bring!" + "toast_bring": "🛒 Product finished → added to Bring!", + "toast_opened_finished": "🔓 Opened package of {name} finished!", + "disambiguation_hint": "What do you mean by \"all done\"?", + "disambiguation_all": "🗑️ Finish EVERYTHING ({qty})" }, "product": { "title_new": "New Product", diff --git a/translations/it.json b/translations/it.json index 9f2efd9..7cf3ca0 100644 --- a/translations/it.json +++ b/translations/it.json @@ -87,6 +87,7 @@ "quick_recipe": "🍳 Ricetta veloce con prodotti in scadenza", "banner_review_title": "Quantità anomala", "banner_review_action_ok": "È corretto", + "banner_review_action_finish": "🗑️ È finito tutto", "banner_review_action_edit": "Correggi", "banner_review_action_weigh": "Pesa", "banner_review_dismiss": "Ignora", @@ -237,7 +238,10 @@ "when_tomorrow": "scade domani", "when_days": "scade tra {n} giorni", "toast_used": "📤 Usato {qty} di {name}", - "toast_bring": "🛒 Prodotto finito → aggiunto a Bring!" + "toast_bring": "🛒 Prodotto finito → aggiunto a Bring!", + "toast_opened_finished": "🔓 Confezione aperta di {name} finita!", + "disambiguation_hint": "Cosa intendi con \"finito tutto\"?", + "disambiguation_all": "🗑️ Finito TUTTO ({qty})" }, "product": { "title_new": "Nuovo Prodotto",