feat: keep qty=0 instead of auto-delete, ask user to confirm via banner
- useFromInventory: replace DELETE with UPDATE qty=0 when stock hits 0
(both normal path and use-all-locations path)
- listInventory: add WHERE quantity > 0 so qty=0 rows are invisible in
the regular inventory list
- New API actions: inventory_finished_items (query) and
inventory_confirm_finished (delete after user confirms)
- Banner: new 'finished' type (priority 600, above anomalies)
Shows: '{name} — è finito?' with two buttons
'Sì, è finito' → permanently deletes the qty=0 row
'No, ne ho ancora' → navigates to add-inventory form
- i18n: banner_finished_* and toast.product_finished_confirmed (it/en/de)
- DB migration: restored 75 auto-deleted products (last 30 days) as
qty=0 inventory rows so they appear in the banner queue
This commit is contained in:
+45
-3
@@ -180,6 +180,12 @@ try {
|
|||||||
case 'inventory_delete':
|
case 'inventory_delete':
|
||||||
deleteInventory($db);
|
deleteInventory($db);
|
||||||
break;
|
break;
|
||||||
|
case 'inventory_finished_items':
|
||||||
|
getFinishedItems($db);
|
||||||
|
break;
|
||||||
|
case 'inventory_confirm_finished':
|
||||||
|
confirmFinished($db);
|
||||||
|
break;
|
||||||
case 'inventory_summary':
|
case 'inventory_summary':
|
||||||
inventorySummary($db);
|
inventorySummary($db);
|
||||||
break;
|
break;
|
||||||
@@ -701,10 +707,11 @@ function listInventory(PDO $db): void {
|
|||||||
COALESCE(i.vacuum_sealed, 0) as vacuum_sealed, i.opened_at
|
COALESCE(i.vacuum_sealed, 0) as vacuum_sealed, i.opened_at
|
||||||
FROM inventory i
|
FROM inventory i
|
||||||
JOIN products p ON i.product_id = p.id
|
JOIN products p ON i.product_id = p.id
|
||||||
|
WHERE i.quantity > 0
|
||||||
";
|
";
|
||||||
$params = [];
|
$params = [];
|
||||||
if (!empty($location)) {
|
if (!empty($location)) {
|
||||||
$query .= " WHERE i.location = ?";
|
$query .= " AND i.location = ?";
|
||||||
$params[] = $location;
|
$params[] = $location;
|
||||||
}
|
}
|
||||||
$query .= " ORDER BY p.name ASC";
|
$query .= " ORDER BY p.name ASC";
|
||||||
@@ -893,7 +900,7 @@ function useFromInventory(PDO $db): void {
|
|||||||
$totalRemoved = 0;
|
$totalRemoved = 0;
|
||||||
foreach ($allItems as $item) {
|
foreach ($allItems as $item) {
|
||||||
$totalRemoved += $item['quantity'];
|
$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']]);
|
$stmt->execute([$item['id']]);
|
||||||
$type = ($notes === 'Buttato') ? 'waste' : 'out';
|
$type = ($notes === 'Buttato') ? 'waste' : 'out';
|
||||||
$stmt = $db->prepare("INSERT INTO transactions (product_id, type, quantity, location, notes) VALUES (?, ?, ?, ?, ?)");
|
$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']);
|
$actualDeducted = min($quantity, $existing['quantity']);
|
||||||
|
|
||||||
if ($newQty <= 0) {
|
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']]);
|
$stmt->execute([$existing['id']]);
|
||||||
} else {
|
} else {
|
||||||
// Check if item is now opened (first use reduces quantity)
|
// Check if item is now opened (first use reduces quantity)
|
||||||
@@ -1149,6 +1156,41 @@ function deleteInventory(PDO $db): void {
|
|||||||
echo json_encode(['success' => true]);
|
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 {
|
function inventorySummary(PDO $db): void {
|
||||||
$stmt = $db->query("
|
$stmt = $db->query("
|
||||||
SELECT i.location, COUNT(DISTINCT i.product_id) as product_count,
|
SELECT i.location, COUNT(DISTINCT i.product_id) as product_count,
|
||||||
|
|||||||
+55
-1
@@ -2354,10 +2354,11 @@ async function loadBannerAlerts() {
|
|||||||
if (!banner) { console.warn('[Banner] #alert-banner not found'); return; }
|
if (!banner) { console.warn('[Banner] #alert-banner not found'); return; }
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [invData, predData, anomalyData] = await Promise.all([
|
const [invData, predData, anomalyData, finishedData] = await Promise.all([
|
||||||
api('inventory_list'),
|
api('inventory_list'),
|
||||||
api('consumption_predictions').catch(err => { console.warn('[Banner] predictions fetch failed:', err); return { predictions: [] }; }),
|
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_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 items = invData.inventory || [];
|
||||||
const confirmed = getReviewConfirmed();
|
const confirmed = getReviewConfirmed();
|
||||||
@@ -2400,6 +2401,13 @@ async function loadBannerAlerts() {
|
|||||||
_bannerQueue.push({ type: 'anomaly', data: an });
|
_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)
|
// Sort by priority (highest first)
|
||||||
_bannerQueue.sort((a, b) => _bannerPriority(b) - _bannerPriority(a));
|
_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)
|
// Phantom (inflated qty) = 250, Missing = 260 (slightly higher, means data is clearly wrong)
|
||||||
return entry.data.direction === 'missing' ? 260 : 250;
|
return entry.data.direction === 'missing' ? 260 : 250;
|
||||||
}
|
}
|
||||||
|
case 'finished':
|
||||||
|
return 600; // product ran out — confirm before removing from DB
|
||||||
default:
|
default:
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -2542,6 +2552,16 @@ function renderBannerItem() {
|
|||||||
}
|
}
|
||||||
actionsEl.innerHTML = btns;
|
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 = `<button class="btn-banner btn-banner-ok" onclick="confirmBannerFinished()">${t('dashboard.banner_finished_action_yes')}</button>`;
|
||||||
|
btns += `<button class="btn-banner btn-banner-edit" onclick="notFinishedBannerAction()">${t('dashboard.banner_finished_action_no')}</button>`;
|
||||||
|
actionsEl.innerHTML = btns;
|
||||||
|
|
||||||
} else if (entry.type === 'anomaly') {
|
} else if (entry.type === 'anomaly') {
|
||||||
const an = entry.data;
|
const an = entry.data;
|
||||||
const isPhantom = an.direction === 'phantom';
|
const isPhantom = an.direction === 'phantom';
|
||||||
@@ -2699,6 +2719,40 @@ function dismissBannerExpiring() {
|
|||||||
dismissBannerItem();
|
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 ---
|
// --- Banner swipe navigation ---
|
||||||
let _bannerTouchStartX = 0;
|
let _bannerTouchStartX = 0;
|
||||||
let _bannerTouchStartY = 0;
|
let _bannerTouchStartY = 0;
|
||||||
|
|||||||
@@ -105,7 +105,11 @@
|
|||||||
"banner_expiring_today": "Läuft heute ab!",
|
"banner_expiring_today": "Läuft heute ab!",
|
||||||
"banner_expiring_tomorrow": "Läuft morgen ab",
|
"banner_expiring_tomorrow": "Läuft morgen ab",
|
||||||
"banner_expiring_days": "Läuft in {days} Tagen 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": {
|
"inventory": {
|
||||||
"title": "Vorrat",
|
"title": "Vorrat",
|
||||||
@@ -426,6 +430,7 @@
|
|||||||
"finished_to_bring": "🛒 Produkt aufgebraucht → zu Bring! hinzugefügt",
|
"finished_to_bring": "🛒 Produkt aufgebraucht → zu Bring! hinzugefügt",
|
||||||
"thrown_away": "🗑️ {name} weggeworfen!",
|
"thrown_away": "🗑️ {name} weggeworfen!",
|
||||||
"thrown_away_partial": "🗑️ {qty} {unit} von {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",
|
"appliance_added": "Gerät hinzugefügt",
|
||||||
"item_added": "{name} hinzugefügt"
|
"item_added": "{name} hinzugefügt"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -105,7 +105,11 @@
|
|||||||
"banner_expiring_today": "Expires today!",
|
"banner_expiring_today": "Expires today!",
|
||||||
"banner_expiring_tomorrow": "Expires tomorrow",
|
"banner_expiring_tomorrow": "Expires tomorrow",
|
||||||
"banner_expiring_days": "Expires in {days} days",
|
"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": {
|
"inventory": {
|
||||||
"title": "Pantry",
|
"title": "Pantry",
|
||||||
@@ -426,6 +430,7 @@
|
|||||||
"finished_to_bring": "🛒 Product finished → added to Bring!",
|
"finished_to_bring": "🛒 Product finished → added to Bring!",
|
||||||
"thrown_away": "🗑️ {name} thrown away!",
|
"thrown_away": "🗑️ {name} thrown away!",
|
||||||
"thrown_away_partial": "🗑️ Thrown away {qty} {unit} of {name}",
|
"thrown_away_partial": "🗑️ Thrown away {qty} {unit} of {name}",
|
||||||
|
"product_finished_confirmed": "✅ Removed — add it again when you restock",
|
||||||
"appliance_added": "Appliance added",
|
"appliance_added": "Appliance added",
|
||||||
"item_added": "{name} added"
|
"item_added": "{name} added"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -105,7 +105,11 @@
|
|||||||
"banner_expiring_today": "Scade oggi!",
|
"banner_expiring_today": "Scade oggi!",
|
||||||
"banner_expiring_tomorrow": "Scade domani",
|
"banner_expiring_tomorrow": "Scade domani",
|
||||||
"banner_expiring_days": "Scade tra {days} giorni",
|
"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": {
|
"inventory": {
|
||||||
"title": "Dispensa",
|
"title": "Dispensa",
|
||||||
@@ -426,6 +430,7 @@
|
|||||||
"finished_to_bring": "🛒 Prodotto finito → aggiunto a Bring!",
|
"finished_to_bring": "🛒 Prodotto finito → aggiunto a Bring!",
|
||||||
"thrown_away": "🗑️ {name} buttato!",
|
"thrown_away": "🗑️ {name} buttato!",
|
||||||
"thrown_away_partial": "🗑️ Buttato {qty} {unit} di {name}",
|
"thrown_away_partial": "🗑️ Buttato {qty} {unit} di {name}",
|
||||||
|
"product_finished_confirmed": "✅ Rimosso — riaggiungi quando ne ricompri",
|
||||||
"appliance_added": "Elettrodomestico aggiunto",
|
"appliance_added": "Elettrodomestico aggiunto",
|
||||||
"item_added": "{name} aggiunto"
|
"item_added": "{name} aggiunto"
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user