Improve use-flow UX and suppress redundant finished alerts
This commit is contained in:
@@ -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 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.
|
- 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.
|
- 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
|
## ✨ Features
|
||||||
|
|
||||||
|
|||||||
+18
-2
@@ -943,13 +943,22 @@ function useFromInventory(PDO $db): void {
|
|||||||
$stmt->execute([$productId]);
|
$stmt->execute([$productId]);
|
||||||
$allItems = $stmt->fetchAll();
|
$allItems = $stmt->fetchAll();
|
||||||
$totalRemoved = 0;
|
$totalRemoved = 0;
|
||||||
|
$explicitFinish = ($notes !== 'Buttato');
|
||||||
foreach ($allItems as $item) {
|
foreach ($allItems as $item) {
|
||||||
$totalRemoved += $item['quantity'];
|
$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';
|
$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 (?, ?, ?, ?, ?)");
|
||||||
$stmt->execute([$productId, $type, $item['quantity'], $item['location'], $notes]);
|
$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]);
|
echo json_encode(['success' => true, 'remaining' => 0, 'removed' => $totalRemoved]);
|
||||||
return;
|
return;
|
||||||
@@ -1064,6 +1073,13 @@ function useFromInventory(PDO $db): void {
|
|||||||
$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 (?, ?, ?, ?, ?)");
|
||||||
$stmt->execute([$productId, $type, $actualDeducted, $location, $notes]);
|
$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;
|
$remaining = $newQty;
|
||||||
|
|
||||||
// Check if opened part remains (for non-split path)
|
// Check if opened part remains (for non-split path)
|
||||||
|
|||||||
@@ -991,6 +991,30 @@ body {
|
|||||||
transform: scale(0.97);
|
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 ===== */
|
/* ===== QUANTITY CONTROL ===== */
|
||||||
.qty-control {
|
.qty-control {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
+39
-20
@@ -5583,8 +5583,16 @@ let _useNormalUnit = 'pz'; // unit when not in conf mode
|
|||||||
function _renderUseExpiryHint(items) {
|
function _renderUseExpiryHint(items) {
|
||||||
const hintEl = document.getElementById('use-expiry-hint');
|
const hintEl = document.getElementById('use-expiry-hint');
|
||||||
|
|
||||||
// Filtra solo item con scadenza e quantità > 0
|
// Parse YYYY-MM-DD as local noon to avoid timezone edge cases on some engines.
|
||||||
const withExpiry = items.filter(i => i.expiry_date && parseFloat(i.quantity) > 0);
|
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)
|
// Serve almeno 2 item con scadenze diverse (o locazioni diverse con scadenze)
|
||||||
if (withExpiry.length < 2) { hintEl.style.display = 'none'; return; }
|
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; }
|
if (uniqueDates.size < 2 && uniqueLocs.size < 2) { hintEl.style.display = 'none'; return; }
|
||||||
|
|
||||||
// Trova il più vicino alla scadenza
|
// 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 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 today = new Date(); today.setHours(0,0,0,0);
|
||||||
const expDate = new Date(soonest.expiry_date);
|
|
||||||
const diffDays = Math.round((expDate - today) / 86400000);
|
const diffDays = Math.round((expDate - today) / 86400000);
|
||||||
|
|
||||||
const locInfo = LOCATIONS[soonest.location] || { icon: '📦', label: soonest.location };
|
const locInfo = LOCATIONS[soonest.location] || { icon: '📦', label: soonest.location };
|
||||||
@@ -5621,6 +5634,18 @@ function _renderUseExpiryHint(items) {
|
|||||||
hintEl.style.display = 'block';
|
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() {
|
async function loadUseInventoryInfo() {
|
||||||
try {
|
try {
|
||||||
const data = await api('inventory_list');
|
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)
|
// Auto-select the location with an opened package first (use from opened before sealed)
|
||||||
const openedItem = items.find(i => {
|
const openedItem = items.find(_isOpenedInventoryItem);
|
||||||
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 firstLoc = openedItem ? openedItem.location : items[0].location;
|
const firstLoc = openedItem ? openedItem.location : items[0].location;
|
||||||
|
|
||||||
// Build location buttons only for locations where the product exists
|
// 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 locQty = locItems.reduce((s, i) => s + parseFloat(i.quantity), 0);
|
||||||
const u = locItems[0].unit || 'pz';
|
const u = locItems[0].unit || 'pz';
|
||||||
const qtyLabel = formatQuantity(locQty, u, locItems[0].default_quantity, locItems[0].package_unit);
|
const qtyLabel = formatQuantity(locQty, u, locItems[0].default_quantity, locItems[0].package_unit);
|
||||||
return `<button type="button" class="loc-btn ${loc === active ? 'active' : ''}" onclick="selectUseLocation(this, '${loc}')">${locInfo.icon} ${locInfo.label} (${qtyLabel})</button>`;
|
const openedBadge = _locationHasOpenedPackage(items, loc)
|
||||||
|
? ` <span class="loc-opened-badge">🔓 ${t('use.opened_badge')}</span>`
|
||||||
|
: '';
|
||||||
|
return `<button type="button" class="loc-btn ${loc === active ? 'active' : ''}${openedBadge ? ' loc-btn-opened' : ''}" onclick="selectUseLocation(this, '${loc}')">${locInfo.icon} ${locInfo.label}${openedBadge}<br><small>${qtyLabel}</small></button>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
if (prefLoc && productLocations.includes(prefLoc) && productLocations.length > 1) {
|
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;
|
const isConf = unit === 'conf' && pkgSize > 0 && pkgUnit;
|
||||||
|
|
||||||
// Find opened package location
|
// Find opened package location
|
||||||
const openedItem = items.find(i => {
|
const openedItem = items.find(_isOpenedInventoryItem);
|
||||||
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 defaultLoc = openedItem ? openedItem.location : (items.find(i => i.location === location) ? location : items[0].location);
|
const defaultLoc = openedItem ? openedItem.location : (items.find(i => i.location === location) ? location : items[0].location);
|
||||||
|
|
||||||
// Build location buttons
|
// 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 locItems = items.filter(i => i.location === loc);
|
||||||
const locQty = locItems.reduce((s, i) => s + parseFloat(i.quantity), 0);
|
const locQty = locItems.reduce((s, i) => s + parseFloat(i.quantity), 0);
|
||||||
const qtyLabel = formatQuantity(locQty, unit, pkgSize, pkgUnit);
|
const qtyLabel = formatQuantity(locQty, unit, pkgSize, pkgUnit);
|
||||||
return `<button type="button" class="loc-btn ${loc === defaultLoc ? 'active' : ''}" onclick="selectRecipeUseLoc(this, '${loc}')">${locInfo.icon} ${locInfo.label} (${qtyLabel})</button>`;
|
const openedBadge = _locationHasOpenedPackage(items, loc)
|
||||||
|
? ` <span class="loc-opened-badge">🔓 ${t('use.opened_badge')}</span>`
|
||||||
|
: '';
|
||||||
|
return `<button type="button" class="loc-btn ${loc === defaultLoc ? 'active' : ''}${openedBadge ? ' loc-btn-opened' : ''}" onclick="selectRecipeUseLoc(this, '${loc}')">${locInfo.icon} ${locInfo.label}${openedBadge}<br><small>${qtyLabel}</small></button>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
// Build quantity controls
|
// Build quantity controls
|
||||||
|
|||||||
@@ -222,6 +222,7 @@
|
|||||||
"use_all": "🗑️ ALLES verwendet / Aufgebraucht",
|
"use_all": "🗑️ ALLES verwendet / Aufgebraucht",
|
||||||
"submit": "📤 Diese Menge verwenden",
|
"submit": "📤 Diese Menge verwenden",
|
||||||
"available": "📦 Verfügbar:",
|
"available": "📦 Verfügbar:",
|
||||||
|
"opened_badge": "GEOEFFNET",
|
||||||
"not_in_inventory": "⚠️ Produkt nicht im Bestand.",
|
"not_in_inventory": "⚠️ Produkt nicht im Bestand.",
|
||||||
"expiry_warning": "⚠️ Verwende zuerst die{loc}, die am {date} abläuft — {when}!",
|
"expiry_warning": "⚠️ Verwende zuerst die{loc}, die am {date} abläuft — {when}!",
|
||||||
"throw_title": "🗑️ Produkt entsorgen",
|
"throw_title": "🗑️ Produkt entsorgen",
|
||||||
|
|||||||
@@ -222,6 +222,7 @@
|
|||||||
"use_all": "🗑️ Used ALL / Finished",
|
"use_all": "🗑️ Used ALL / Finished",
|
||||||
"submit": "📤 Use this quantity",
|
"submit": "📤 Use this quantity",
|
||||||
"available": "📦 Available:",
|
"available": "📦 Available:",
|
||||||
|
"opened_badge": "OPENED",
|
||||||
"not_in_inventory": "⚠️ Product not in inventory.",
|
"not_in_inventory": "⚠️ Product not in inventory.",
|
||||||
"expiry_warning": "⚠️ Use first the one{loc} that expires on {date} — {when}!",
|
"expiry_warning": "⚠️ Use first the one{loc} that expires on {date} — {when}!",
|
||||||
"throw_title": "🗑️ Discard Product",
|
"throw_title": "🗑️ Discard Product",
|
||||||
|
|||||||
@@ -222,6 +222,7 @@
|
|||||||
"use_all": "🗑️ Usato TUTTO / Finito",
|
"use_all": "🗑️ Usato TUTTO / Finito",
|
||||||
"submit": "📤 Usa questa quantità",
|
"submit": "📤 Usa questa quantità",
|
||||||
"available": "📦 Disponibile:",
|
"available": "📦 Disponibile:",
|
||||||
|
"opened_badge": "APERTO",
|
||||||
"not_in_inventory": "⚠️ Prodotto non presente nell'inventario.",
|
"not_in_inventory": "⚠️ Prodotto non presente nell'inventario.",
|
||||||
"expiry_warning": "⚠️ Usa prima quella{loc} che scade il {date} — {when}!",
|
"expiry_warning": "⚠️ Usa prima quella{loc} che scade il {date} — {when}!",
|
||||||
"throw_title": "🗑️ Butta Prodotto",
|
"throw_title": "🗑️ Butta Prodotto",
|
||||||
|
|||||||
Reference in New Issue
Block a user