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",