diff --git a/api/index.php b/api/index.php index 9098fbe..27020f7 100644 --- a/api/index.php +++ b/api/index.php @@ -180,6 +180,12 @@ try { case 'inventory_delete': deleteInventory($db); break; + case 'inventory_finished_items': + getFinishedItems($db); + break; + case 'inventory_confirm_finished': + confirmFinished($db); + break; case 'inventory_summary': inventorySummary($db); break; @@ -701,10 +707,11 @@ function listInventory(PDO $db): void { COALESCE(i.vacuum_sealed, 0) as vacuum_sealed, i.opened_at FROM inventory i JOIN products p ON i.product_id = p.id + WHERE i.quantity > 0 "; $params = []; if (!empty($location)) { - $query .= " WHERE i.location = ?"; + $query .= " AND i.location = ?"; $params[] = $location; } $query .= " ORDER BY p.name ASC"; @@ -893,7 +900,7 @@ function useFromInventory(PDO $db): void { $totalRemoved = 0; foreach ($allItems as $item) { $totalRemoved += $item['quantity']; - $stmt = $db->prepare("DELETE FROM inventory WHERE id = ?"); + $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 (?, ?, ?, ?, ?)"); @@ -971,7 +978,7 @@ function useFromInventory(PDO $db): void { $actualDeducted = min($quantity, $existing['quantity']); if ($newQty <= 0) { - $stmt = $db->prepare("DELETE FROM inventory WHERE id = ?"); + $stmt = $db->prepare("UPDATE inventory SET quantity = 0, updated_at = CURRENT_TIMESTAMP WHERE id = ?"); $stmt->execute([$existing['id']]); } else { // Check if item is now opened (first use reduces quantity) @@ -1149,6 +1156,41 @@ function deleteInventory(PDO $db): void { echo json_encode(['success' => true]); } +/** + * Returns products whose entire inventory is at quantity = 0 + * (auto-set when stock ran out, pending user confirmation to permanently remove). + */ +function getFinishedItems(PDO $db): void { + $rows = $db->query(" + SELECT p.id AS product_id, p.name, p.brand, p.unit, p.default_quantity, p.package_unit, p.image_url, + MIN(i.location) AS location, + MAX(i.updated_at) AS updated_at + FROM products p + JOIN inventory i ON i.product_id = p.id + WHERE NOT EXISTS ( + SELECT 1 FROM inventory i2 WHERE i2.product_id = p.id AND i2.quantity > 0 + ) + GROUP BY p.id + ORDER BY MAX(i.updated_at) DESC + ")->fetchAll(PDO::FETCH_ASSOC); + echo json_encode(['success' => true, 'finished' => $rows], JSON_UNESCAPED_UNICODE); +} + +/** + * Permanently delete all qty=0 inventory rows for a product after user confirms it is finished. + */ +function confirmFinished(PDO $db): void { + $input = json_decode(file_get_contents('php://input'), true); + $productId = (int)($input['product_id'] ?? 0); + if (!$productId) { + http_response_code(400); + echo json_encode(['error' => 'product_id required']); + return; + } + $db->prepare("DELETE FROM inventory WHERE product_id = ? AND quantity = 0")->execute([$productId]); + echo json_encode(['success' => true]); +} + function inventorySummary(PDO $db): void { $stmt = $db->query(" SELECT i.location, COUNT(DISTINCT i.product_id) as product_count, diff --git a/assets/js/app.js b/assets/js/app.js index 7926468..88e9e5e 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -2354,10 +2354,11 @@ async function loadBannerAlerts() { if (!banner) { console.warn('[Banner] #alert-banner not found'); return; } try { - const [invData, predData, anomalyData] = await Promise.all([ + const [invData, predData, anomalyData, finishedData] = await Promise.all([ api('inventory_list'), api('consumption_predictions').catch(err => { console.warn('[Banner] predictions fetch failed:', err); return { predictions: [] }; }), api('inventory_anomalies').catch(err => { console.warn('[Banner] anomalies fetch failed:', err); return { anomalies: [] }; }), + api('inventory_finished_items').catch(err => { console.warn('[Banner] finished_items fetch failed:', err); return { finished: [] }; }), ]); const items = invData.inventory || []; const confirmed = getReviewConfirmed(); @@ -2400,6 +2401,13 @@ async function loadBannerAlerts() { _bannerQueue.push({ type: 'anomaly', data: an }); }); + // 6. Finished products: inventory hit 0, waiting for user confirmation + const finished = finishedData.finished || []; + finished.forEach(fin => { + if (confirmed['fin_' + fin.product_id]) return; + _bannerQueue.push({ type: 'finished', data: fin }); + }); + // Sort by priority (highest first) _bannerQueue.sort((a, b) => _bannerPriority(b) - _bannerPriority(a)); @@ -2451,6 +2459,8 @@ function _bannerPriority(entry) { // Phantom (inflated qty) = 250, Missing = 260 (slightly higher, means data is clearly wrong) return entry.data.direction === 'missing' ? 260 : 250; } + case 'finished': + return 600; // product ran out — confirm before removing from DB default: return 0; } @@ -2542,6 +2552,16 @@ function renderBannerItem() { } actionsEl.innerHTML = btns; + } else if (entry.type === 'finished') { + const fin = entry.data; + banner.className = 'alert-banner banner-finished'; + iconEl.textContent = '📦'; + titleEl.textContent = `${fin.name}${fin.brand ? ' (' + fin.brand + ')' : ''} — ${t('dashboard.banner_finished_title')}`; + detailEl.textContent = t('dashboard.banner_finished_detail', { name: fin.name }); + let btns = ``; + btns += ``; + actionsEl.innerHTML = btns; + } else if (entry.type === 'anomaly') { const an = entry.data; const isPhantom = an.direction === 'phantom'; @@ -2699,6 +2719,40 @@ function dismissBannerExpiring() { dismissBannerItem(); } +async function confirmBannerFinished() { + const entry = _bannerQueue[_bannerIndex]; + if (!entry || entry.type !== 'finished') return; + const productId = entry.data.product_id; + try { + await api('inventory_confirm_finished', {}, 'POST', { product_id: productId }); + } catch(e) {} + setReviewConfirmed('fin_' + productId); + showToast(t('toast.product_finished_confirmed'), 'success'); + dismissBannerItem(); +} + +async function notFinishedBannerAction() { + const entry = _bannerQueue[_bannerIndex]; + if (!entry || entry.type !== 'finished') return; + const productId = entry.data.product_id; + // Remove from this session's queue (will re-appear next load if still at qty=0) + dismissBannerItem(); + showLoading(true); + try { + const data = await api('product_get', { id: productId }); + showLoading(false); + if (data.product) { + currentProduct = data.product; + showAddForm(); + } else { + showToast(t('error.not_found'), 'error'); + } + } catch(e) { + showLoading(false); + showToast(t('error.connection'), 'error'); + } +} + // --- Banner swipe navigation --- let _bannerTouchStartX = 0; let _bannerTouchStartY = 0; diff --git a/translations/de.json b/translations/de.json index 52776d6..7946396 100644 --- a/translations/de.json +++ b/translations/de.json @@ -105,7 +105,11 @@ "banner_expiring_today": "Läuft heute ab!", "banner_expiring_tomorrow": "Läuft morgen ab", "banner_expiring_days": "Läuft in {days} Tagen ab", - "banner_expiring_action_use": "Jetzt verwenden" + "banner_expiring_action_use": "Jetzt verwenden", + "banner_finished_title": "aufgebraucht?", + "banner_finished_detail": "Ich habe vermerkt, dass {name} auf null gesunken ist. Ist es wirklich leer, oder hast du noch welches?", + "banner_finished_action_yes": "Ja, aufgebraucht", + "banner_finished_action_no": "Nein, ich habe noch welches" }, "inventory": { "title": "Vorrat", @@ -426,6 +430,7 @@ "finished_to_bring": "🛒 Produkt aufgebraucht → zu Bring! hinzugefügt", "thrown_away": "🗑️ {name} weggeworfen!", "thrown_away_partial": "🗑️ {qty} {unit} von {name} weggeworfen", + "product_finished_confirmed": "✅ Entfernt — wieder hinzufügen, wenn du nachkaufst", "appliance_added": "Gerät hinzugefügt", "item_added": "{name} hinzugefügt" }, diff --git a/translations/en.json b/translations/en.json index a2c73d8..d0db7ba 100644 --- a/translations/en.json +++ b/translations/en.json @@ -105,7 +105,11 @@ "banner_expiring_today": "Expires today!", "banner_expiring_tomorrow": "Expires tomorrow", "banner_expiring_days": "Expires in {days} days", - "banner_expiring_action_use": "Use now" + "banner_expiring_action_use": "Use now", + "banner_finished_title": "finished?", + "banner_finished_detail": "I recorded that {name} reached zero stock. Is it really gone, or do you still have some?", + "banner_finished_action_yes": "Yes, it's done", + "banner_finished_action_no": "No, I still have some" }, "inventory": { "title": "Pantry", @@ -426,6 +430,7 @@ "finished_to_bring": "🛒 Product finished → added to Bring!", "thrown_away": "🗑️ {name} thrown away!", "thrown_away_partial": "🗑️ Thrown away {qty} {unit} of {name}", + "product_finished_confirmed": "✅ Removed — add it again when you restock", "appliance_added": "Appliance added", "item_added": "{name} added" }, diff --git a/translations/it.json b/translations/it.json index fe82ea9..942776b 100644 --- a/translations/it.json +++ b/translations/it.json @@ -105,7 +105,11 @@ "banner_expiring_today": "Scade oggi!", "banner_expiring_tomorrow": "Scade domani", "banner_expiring_days": "Scade tra {days} giorni", - "banner_expiring_action_use": "Usa ora" + "banner_expiring_action_use": "Usa ora", + "banner_finished_title": "è finito?", + "banner_finished_detail": "Ho registrato che {name} ha toccato quota zero. È davvero finito o hai ancora delle scorte?", + "banner_finished_action_yes": "Sì, è finito", + "banner_finished_action_no": "No, ne ho ancora" }, "inventory": { "title": "Dispensa", @@ -426,6 +430,7 @@ "finished_to_bring": "🛒 Prodotto finito → aggiunto a Bring!", "thrown_away": "🗑️ {name} buttato!", "thrown_away_partial": "🗑️ Buttato {qty} {unit} di {name}", + "product_finished_confirmed": "✅ Rimosso — riaggiungi quando ne ricompri", "appliance_added": "Elettrodomestico aggiunto", "item_added": "{name} aggiunto" },