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:
+52
-1
@@ -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]);
|
||||
|
||||
@@ -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
@@ -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>';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user