feat: bottone 'Usa' per ogni ingrediente ricetta - scala dalla dispensa

- Backend: arricchisce risposta ricetta con product_id, location per ingredienti from_pantry
- Matching fuzzy nome ingrediente AI → prodotto inventario (exact/contains/word overlap)
- Frontend: bottone '📦 Usa' per ogni ingrediente catalogato in dispensa
- Click → chiama inventory_use API → scala 1 unità → feedback visivo (barrato + verde)
- Ingredienti non in dispensa (🛒) mostrati senza bottone
This commit is contained in:
dadaloop82
2026-03-10 17:29:49 +00:00
parent 5258c11f51
commit fa4dc6ae88
3 changed files with 154 additions and 5 deletions
+52 -1
View File
@@ -688,7 +688,7 @@ function generateRecipe(PDO $db): void {
// Fetch all inventory items with expiry info
$stmt = $db->query("
SELECT p.name, p.brand, p.category, i.quantity, p.unit, i.location, i.expiry_date,
SELECT p.id AS product_id, p.name, p.brand, p.category, i.quantity, p.unit, i.location, i.expiry_date,
CASE WHEN i.expiry_date IS NOT NULL THEN julianday(i.expiry_date) - julianday('now') ELSE 999 END AS days_left
FROM inventory i
JOIN products p ON p.id = i.product_id
@@ -811,6 +811,57 @@ PROMPT;
$recipe = json_decode($text, true);
if ($recipe && !empty($recipe['title'])) {
// Enrich from_pantry ingredients with product_id and location for "use" feature
if (!empty($recipe['ingredients'])) {
foreach ($recipe['ingredients'] as &$ing) {
if (!empty($ing['from_pantry'])) {
$ingNameLower = mb_strtolower(trim($ing['name']), 'UTF-8');
$bestMatch = null;
$bestScore = 0;
foreach ($items as $item) {
$itemNameLower = mb_strtolower(trim($item['name']), 'UTF-8');
$score = 0;
// Exact match
if ($ingNameLower === $itemNameLower) {
$score = 100;
}
// Ingredient name contained in product name
elseif (mb_strpos($itemNameLower, $ingNameLower) !== false) {
$score = 80;
}
// Product name contained in ingredient name
elseif (mb_strpos($ingNameLower, $itemNameLower) !== false) {
$score = 70;
}
// Word-level matching: check if key words overlap
else {
$ingWords = preg_split('/\s+/', $ingNameLower);
$itemWords = preg_split('/\s+/', $itemNameLower);
$common = array_intersect($ingWords, $itemWords);
if (count($common) > 0) {
$score = (count($common) / max(count($ingWords), 1)) * 60;
}
}
if ($score > $bestScore) {
$bestScore = $score;
$bestMatch = $item;
}
}
// Only match if score is reasonable (> 30)
if ($bestMatch && $bestScore > 30) {
$ing['product_id'] = (int)$bestMatch['product_id'];
$ing['location'] = $bestMatch['location'];
$ing['available_qty'] = $bestMatch['quantity'] . ' ' . $bestMatch['unit'];
}
}
}
unset($ing);
}
echo json_encode(['success' => true, 'recipe' => $recipe]);
} else {
echo json_encode(['success' => false, 'error' => 'Impossibile generare la ricetta', 'raw' => $text]);
+57
View File
@@ -2031,6 +2031,63 @@ body {
line-height: 1.5;
}
/* Recipe ingredient use buttons */
.recipe-ingredients {
list-style: none;
padding: 0;
}
.recipe-ingredient {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 6px 0;
border-bottom: 1px solid var(--bg-light);
}
.recipe-ingredient:last-child {
border-bottom: none;
}
.recipe-ing-text {
flex: 1;
min-width: 0;
line-height: 1.4;
}
.btn-use-ingredient {
flex-shrink: 0;
background: var(--primary);
color: #fff;
border: none;
border-radius: var(--radius-sm);
padding: 4px 10px;
font-size: 0.78rem;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
transition: background 0.2s, opacity 0.2s;
}
.btn-use-ingredient:active {
opacity: 0.7;
}
.btn-use-ingredient:disabled {
cursor: default;
opacity: 0.6;
}
.btn-use-ingredient.btn-used {
background: var(--success);
}
.recipe-ing-used .recipe-ing-text {
text-decoration: line-through;
opacity: 0.5;
}
/* ===== AI IDENTIFICATION RESULTS ===== */
.ai-identified-card {
background: var(--bg-light);
+44 -3
View File
@@ -2655,6 +2655,40 @@ function adjustRecipePersons(delta) {
input.value = val;
}
async function useRecipeIngredient(idx, productId, location, btn) {
if (btn.disabled) return;
btn.disabled = true;
btn.textContent = '⏳...';
try {
const result = await api('inventory_use', {}, 'POST', {
product_id: productId,
quantity: 1,
use_all: false,
location: location
});
if (result.success) {
const li = document.getElementById(`recipe-ing-${idx}`);
if (li) {
li.classList.add('recipe-ing-used');
}
btn.textContent = '✔️ Scalato';
btn.classList.add('btn-used');
showToast('📦 Ingrediente scalato dalla dispensa!', 'success');
} else {
btn.disabled = false;
btn.textContent = '📦 Usa';
showToast(result.error || 'Errore nello scalare', 'error');
}
} catch (err) {
console.error('Use ingredient error:', err);
btn.disabled = false;
btn.textContent = '📦 Usa';
showToast('Errore di connessione', 'error');
}
}
async function generateRecipe() {
const meal = getMealType();
const persons = parseInt(document.getElementById('recipe-persons').value) || 1;
@@ -2695,10 +2729,17 @@ async function generateRecipe() {
}
// Ingredients
html += '<h3>🧾 Ingredienti</h3><ul>';
(r.ingredients || []).forEach(ing => {
html += '<h3>🧾 Ingredienti</h3><ul class="recipe-ingredients">';
(r.ingredients || []).forEach((ing, idx) => {
if (ing.from_pantry && ing.product_id) {
html += `<li class="recipe-ingredient" id="recipe-ing-${idx}">`;
html += `<span class="recipe-ing-text"><strong>${ing.name}</strong>: ${ing.qty} ✅</span>`;
html += `<button class="btn-use-ingredient" onclick="useRecipeIngredient(${idx}, ${ing.product_id}, '${(ing.location || 'dispensa').replace(/'/g, "\\'")}', this)" title="Scala dalla dispensa">📦 Usa</button>`;
html += `</li>`;
} else {
const pantryIcon = ing.from_pantry ? ' ✅' : ' 🛒';
html += `<li><strong>${ing.name}</strong>: ${ing.qty}${pantryIcon}</li>`;
html += `<li class="recipe-ingredient"><span class="recipe-ing-text"><strong>${ing.name}</strong>: ${ing.qty}${pantryIcon}</span></li>`;
}
});
html += '</ul>';