Add cookbook recipe import to shopping list with deduplication and quantity merging

This commit is contained in:
2026-10-29 20:11:18 +01:00
parent e11762e264
commit 434e401918
2 changed files with 448 additions and 4 deletions
+6
View File
@@ -24,6 +24,9 @@
<span id="cookmode-nav-badge" class="cb-badge-pill">Aus</span>
</button>
{% if not is_shared %}
<a href="/shopping-list?import_recipe_id={{ recipe.id }}" class="cb-btn cb-btn-outline" title="Zutaten auf die Einkaufsliste übernehmen">
<span>🛒</span> <span>Auf Einkaufsliste</span>
</a>
<a href="/cookbook" class="cb-btn cb-btn-outline">
<span>📖</span> <span>Alle Rezepte</span>
</a>
@@ -65,6 +68,9 @@
</button>
<hr class="cb-mobile-divider">
{% if not is_shared %}
<a href="/shopping-list?import_recipe_id={{ recipe.id }}" class="cb-mobile-item">
<span>🛒</span> <span>Auf Einkaufsliste</span>
</a>
<button type="button" onclick="openShareModal()" class="cb-mobile-item">
<span>🔗</span> <span>Rezept teilen</span>
</button>
+442 -4
View File
@@ -541,6 +541,9 @@
<button type="button" onclick="openMealPlanImportModal()" class="cb-nav-btn" title="Zutaten aus Wochenplan holen">
<span>📅</span> <span>Aus Wochenplan holen</span>
</button>
<button type="button" onclick="openCookbookImportModal()" class="cb-nav-btn" title="Zutaten aus einem Kochbuch-Rezept holen">
<span>📖</span> <span>Aus Kochbuch holen</span>
</button>
<button type="button" onclick="deleteCheckedItems()" class="cb-nav-btn" title="Erledigte Artikel löschen">
<span>🧹</span> <span>Erledigte löschen</span>
</button>
@@ -573,6 +576,9 @@
<button type="button" onclick="handleMobileMenuAction(openMealPlanImportModal)" class="cb-mobile-item">
<span>📅</span> <span>Aus Wochenplan holen</span>
</button>
<button type="button" onclick="handleMobileMenuAction(openCookbookImportModal)" class="cb-mobile-item">
<span>📖</span> <span>Aus Kochbuch holen</span>
</button>
<button type="button" onclick="handleMobileMenuAction(deleteCheckedItems)" class="cb-mobile-item">
<span>🧹</span> <span>Erledigte löschen</span>
</button>
@@ -616,10 +622,15 @@
<div class="shop-empty-state" id="empty-state" style="display: none;">
<div class="shop-empty-icon">🎉</div>
<h2 class="shop-empty-title">Alles eingekauft!</h2>
<p class="shop-empty-desc">Deine Einkaufsliste ist leer. Tippe unten rechts auf das <strong>+</strong>, um einen neuen Eintrag zu notieren, oder übernimm Zutaten direkt aus dem Wochenplan.</p>
<button type="button" class="cb-btn cb-btn-green" onclick="openMealPlanImportModal()">
<span>📅</span> <span>Aus Wochenplan importieren</span>
</button>
<p class="shop-empty-desc">Deine Einkaufsliste ist leer. Tippe unten rechts auf das <strong>+</strong>, um einen neuen Eintrag zu notieren, oder übernimm Zutaten direkt aus dem Wochenplan oder Kochbuch.</p>
<div style="display: flex; gap: 10px; justify-content: center; flex-wrap: wrap;">
<button type="button" class="cb-btn cb-btn-green" onclick="openMealPlanImportModal()">
<span>📅</span> <span>Aus Wochenplan holen</span>
</button>
<button type="button" class="cb-btn cb-btn-creme" onclick="openCookbookImportModal()" style="border: 1.5px solid var(--shop-green); color: var(--shop-green); font-weight: 700;">
<span>📖</span> <span>Aus Kochbuch holen</span>
</button>
</div>
</div>
</main>
@@ -816,6 +827,31 @@
</div>
</div>
<!-- ========================================== -->
<!-- MODAL: ZUTATEN AUS KOCHBUCH HOLEN -->
<!-- ========================================== -->
<div id="modal-import-cookbook" class="cb-modal-backdrop no-print" onclick="onBackdropClick(event, 'modal-import-cookbook')">
<div class="cb-modal-card" style="max-width: 600px;">
<div class="cb-modal-header">
<h3 class="cb-modal-title"><span>📖</span> Zutaten aus Kochbuch holen</h3>
<button type="button" class="cb-modal-close" onclick="closeModal('modal-import-cookbook')" title="Schließen">&times;</button>
</div>
<div class="cb-modal-body">
<div id="cookbook-import-body" style="min-height: 120px;">
<!-- Dynamischer Inhalt -->
</div>
<div class="modal-btn-row">
<button type="button" class="cb-btn cb-btn-secondary" onclick="closeModal('modal-import-cookbook')">
Schließen
</button>
<button type="button" class="cb-btn cb-btn-green" id="btn-confirm-import-cookbook" onclick="confirmImportCookbookIngredients()" disabled>
Ausgewählte übernehmen
</button>
</div>
</div>
</div>
</div>
<!-- ========================================== -->
<!-- MODAL: SCHNELL-REGALWAHL (1-TAP) -->
<!-- ========================================== -->
@@ -1813,6 +1849,398 @@
}
}
// ==========================================
// MODAL: ZUTATEN AUS KOCHBUCH HOLEN
// ==========================================
let allCookbookRecipes = null;
let selectedCookbookRecipes = [];
const CHAPTER_NAMES = {
1: "Suppen & Eintöpfe",
2: "Traditionell & Basics",
3: "Pasta & Nudelgerichte",
4: "Kartoffeln",
5: "Reis & Getreide",
6: "Teig & Gebackenes",
7: "Asiatische Küche",
8: "Süßspeisen",
9: "Desserts",
10: "Kuchen & Gebäck"
};
function cleanIngredientName(name) {
if (!name) return '';
let str = name.trim().replace(/^[ \-\*\s]+/, '');
str = str.replace(/\s*\([^)]*\)/g, '');
if (str.includes(',')) {
const parts = str.split(',');
if (parts[1].trim().split(/\s+/).length <= 4) {
str = parts[0];
}
}
str = str.replace(/[\s.,;:]+$/, '').replace(/\s+/g, ' ').trim();
if (str && str[0] === str[0].toLowerCase()) {
str = str[0].toUpperCase() + str.slice(1);
}
return str;
}
function getNormalizedIngredientKey(name) {
if (!name) return "";
let str = name.toLowerCase().replace(/^[ \-\*\s]+/, "").replace(/\s*\([^)]*\)/g, "");
if (str.includes(",")) str = str.split(",")[0];
str = str.replace(/[\s.,;:]+$/, "").replace(/\s+/g, " ").trim();
if (str.endsWith("en") && str.length > 5) str = str.slice(0, -2);
else if (str.endsWith("n") && str.length > 4 && !str.endsWith("in")) str = str.slice(0, -1);
return str;
}
function findMatchingOpenShoppingItem(name) {
const k = getNormalizedIngredientKey(name);
if (!k) return null;
return allItems.find(it => !it.checked && getNormalizedIngredientKey(it.item_name) === k) || null;
}
async function openCookbookImportModal(preselectedRecipeId = null) {
openModal('modal-import-cookbook');
const body = document.getElementById('cookbook-import-body');
const confirmBtn = document.getElementById('btn-confirm-import-cookbook');
if (confirmBtn) {
confirmBtn.disabled = true;
confirmBtn.textContent = 'Ausgewählte übernehmen';
}
if (!allCookbookRecipes) {
body.innerHTML = `
<div style="text-align: center; padding: 35px 20px; color: #555;">
<div class="shop-spinner shop-spinner-green" style="margin-bottom: 12px;"></div>
<div style="font-weight: 600; font-size: 0.95rem; color: var(--shop-green);">Rezepte werden geladen...</div>
</div>
`;
try {
const res = await fetch('/api/recipes');
if (!res.ok) throw new Error('Fehler beim Laden der Rezepte');
allCookbookRecipes = await res.json();
} catch (err) {
console.error('Fehler beim Laden der Rezepte:', err);
body.innerHTML = `
<div style="text-align: center; padding: 25px; color: #b02a37;">
Fehler beim Laden der Rezepte. Bitte stelle sicher, dass du online bist.
</div>
`;
return;
}
}
if (preselectedRecipeId) {
selectedCookbookRecipes = [];
}
renderCookbookImportUI();
if (preselectedRecipeId) {
await addCookbookRecipeToSelection(preselectedRecipeId);
}
}
function renderCookbookImportUI() {
const body = document.getElementById('cookbook-import-body');
if (!body) return;
const recipesByChapter = {};
allCookbookRecipes.forEach(r => {
const ch = r.chapter_number || 1;
if (!recipesByChapter[ch]) recipesByChapter[ch] = [];
recipesByChapter[ch].push(r);
});
const sortedChapters = Object.keys(recipesByChapter).map(Number).sort((a,b) => a - b);
let optionsHtml = '<option value="">-- Rezept auswählen... --</option>';
sortedChapters.forEach(ch => {
const chName = CHAPTER_NAMES[ch] || `Kapitel ${ch}`;
optionsHtml += `<optgroup label="Kapitel ${ch}: ${escapeAttr(chName)}">`;
recipesByChapter[ch].forEach(r => {
const sub = r.subtitle ? ` (${r.subtitle})` : '';
optionsHtml += `<option value="${r.id}">${escapeHtml(r.title)}${escapeHtml(sub)}</option>`;
});
optionsHtml += `</optgroup>`;
});
body.innerHTML = `
<div style="margin-bottom: 14px; background: rgba(30, 77, 59, 0.04); border: 1.5px solid rgba(30, 77, 59, 0.15); border-radius: 8px; padding: 12px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; flex-wrap: wrap; gap: 4px;">
<label style="font-family: var(--font-hand); font-size: 1.15rem; font-weight: 700; color: var(--shop-green); margin: 0;">
📖 Rezept aus dem Kochbuch wählen:
</label>
<span style="font-size: 0.8rem; color: #666;">${allCookbookRecipes.length} Rezepte</span>
</div>
<div style="display: flex; flex-direction: column; gap: 8px; position: relative;">
<input type="text" id="cookbook-search-input" class="cb-input"
placeholder="🔍 Tippen zum Suchen (z. B. Chili, Strudel, Suppe...)"
oninput="onFilterCookbookSearch(this.value)"
autocomplete="off"
style="width: 100%; box-sizing: border-box; padding: 8px 12px; font-size: 0.92rem; border-radius: 6px;">
<div id="cookbook-search-dropdown-results" style="display: none; max-height: 180px; overflow-y: auto; background: #fff; border: 1.5px solid var(--shop-green); border-radius: 6px; box-shadow: 0 4px 12px rgba(0,0,0,0.15); z-index: 50;"></div>
<select id="cookbook-recipe-select" class="cb-select" onchange="onSelectCookbookDropdown(this.value)" style="width: 100%; box-sizing: border-box; padding: 8px 12px; font-size: 0.92rem; border-radius: 6px;">
${optionsHtml}
</select>
</div>
</div>
<div id="cookbook-selected-recipes-container" style="max-height: 48vh; overflow-y: auto; display: flex; flex-direction: column; gap: 12px; padding-right: 4px;">
${renderSelectedCookbookRecipeCards()}
</div>
`;
updateCookbookConfirmBtn();
}
function renderSelectedCookbookRecipeCards() {
if (selectedCookbookRecipes.length === 0) {
return `
<div style="text-align: center; padding: 25px 15px; color: #666; background: rgba(30, 77, 59, 0.02); border: 1.5px dashed rgba(30, 77, 59, 0.2); border-radius: 8px;">
<span style="font-size: 2.2rem; display: block; margin-bottom: 6px;">📖</span>
<p style="font-size: 1.05rem; margin: 0 0 6px 0; font-weight: 700; color: var(--shop-green);">Noch kein Rezept ausgewählt</p>
<p style="font-size: 0.88rem; margin: 0; color: #777; max-width: 440px; margin-inline: auto;">
Wähle oben ein Rezept aus, um dessen Zutaten auf deine Einkaufsliste zu setzen. Mengen von bereits vorhandenen Zutaten werden automatisch addiert – ohne Dopplungen!
</p>
</div>
`;
}
return selectedCookbookRecipes.map(r => {
const ingredients = r.parsed_ingredients || [];
const chName = CHAPTER_NAMES[r.chapter_number] || `Kapitel ${r.chapter_number || 1}`;
let itemsHtml = '';
if (ingredients.length === 0) {
itemsHtml = `<div style="color: #888; font-size: 0.88rem; font-style: italic; padding: 6px 0;">Keine Zutaten für dieses Rezept hinterlegt.</div>`;
} else {
itemsHtml = ingredients.map(ing => {
if (ing.is_header) {
return `<div style="font-weight: 700; color: var(--shop-green); font-size: 0.85rem; margin-top: 6px; font-family: var(--font-hand);">${escapeHtml(ing.raw)}</div>`;
}
const cleanName = cleanIngredientName(ing.name || ing.raw || '');
if (!cleanName) return '';
const amtStr = formatAmount(ing.amount_formatted || ing.amount_str || ing.amount, ing.unit);
const match = findMatchingOpenShoppingItem(cleanName);
return `
<label style="display: flex; align-items: center; justify-content: space-between; gap: 8px; font-size: 0.92rem; cursor: pointer; padding: 4px 6px; border-radius: 4px; background: ${match ? 'rgba(46, 125, 50, 0.06)' : 'transparent'};">
<div style="display: flex; align-items: center; gap: 8px;">
<input type="checkbox" class="cookbook-ing-checkbox" checked
data-recipe-id="${r.id}"
data-name="${escapeAttr(cleanName)}"
data-amount="${escapeAttr(ing.amount_formatted || ing.amount_str || (ing.amount !== null && ing.amount !== undefined ? String(ing.amount) : ''))}"
data-unit="${escapeAttr(ing.unit || '')}"
data-source="${escapeAttr(r.title)}"
onchange="updateCookbookConfirmBtn()">
<span>${amtStr ? `<strong>${escapeHtml(amtStr)}</strong> ` : ''}${escapeHtml(cleanName)}</span>
</div>
${match ? `
<span style="font-size: 0.72rem; background: #e8f5e9; color: #2e7d32; border: 1px solid #c8e6c9; border-radius: 10px; padding: 1px 7px; font-weight: 600; white-space: nowrap;" title="Bereits auf der Einkaufsliste – Menge wird addiert!">
➕ wird addiert (${escapeHtml(match.amount ? match.amount + ' ' + (match.unit || '') : 'auf Liste')})
</span>
` : ''}
</label>
`;
}).join('');
}
return `
<div class="cookbook-recipe-card" data-recipe-id="${r.id}" style="background: rgba(30, 77, 59, 0.05); border: 1.5px solid rgba(30, 77, 59, 0.18); border-radius: 8px; padding: 12px;">
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 8px; gap: 8px;">
<div>
<div style="font-family: var(--font-hand); font-size: 1.25rem; font-weight: 700; color: var(--shop-green);">
🍳 ${escapeHtml(r.title)}
</div>
${r.subtitle ? `<div style="font-size: 0.8rem; color: #666; font-style: italic;">${escapeHtml(r.subtitle)}</div>` : ''}
</div>
<div style="display: flex; gap: 6px; align-items: center;">
<span style="font-size: 0.72rem; background: rgba(30, 77, 59, 0.12); color: var(--shop-green); padding: 2px 7px; border-radius: 6px; font-weight: 600;">${escapeHtml(chName)}</span>
<button type="button" onclick="removeCookbookRecipeFromSelection(${r.id})" title="Dieses Rezept entfernen" style="background: none; border: none; color: #b02a37; font-size: 1.15rem; cursor: pointer; padding: 0 4px; line-height: 1;">✕</button>
</div>
</div>
<div style="display: flex; justify-content: flex-end; gap: 10px; margin-bottom: 8px; font-size: 0.82rem;">
<button type="button" onclick="toggleCookbookRecipeChecks(${r.id}, true)" style="background: none; border: none; color: var(--shop-green); font-weight: 700; cursor: pointer; text-decoration: underline;">Alle</button>
<span style="color: #ccc;">|</span>
<button type="button" onclick="toggleCookbookRecipeChecks(${r.id}, false)" style="background: none; border: none; color: #888; font-weight: 600; cursor: pointer; text-decoration: underline;">Keine</button>
</div>
<div style="display: flex; flex-direction: column; gap: 4px;">
${itemsHtml}
</div>
</div>
`;
}).join('');
}
async function addCookbookRecipeToSelection(recipeId) {
recipeId = parseInt(recipeId, 10);
if (!recipeId) return;
if (selectedCookbookRecipes.some(r => r.id === recipeId)) {
showToast('Rezept bereits in der Auswahl');
return;
}
try {
const res = await fetch(`/api/recipes/${recipeId}`);
if (!res.ok) throw new Error('Rezept nicht gefunden');
const recipeData = await res.json();
selectedCookbookRecipes.push(recipeData);
const container = document.getElementById('cookbook-selected-recipes-container');
if (container) {
container.innerHTML = renderSelectedCookbookRecipeCards();
}
updateCookbookConfirmBtn();
} catch (err) {
console.error('Fehler beim Laden des Rezepts:', err);
showToast('Fehler beim Laden des Rezepts');
}
}
function removeCookbookRecipeFromSelection(recipeId) {
selectedCookbookRecipes = selectedCookbookRecipes.filter(r => r.id !== recipeId);
const container = document.getElementById('cookbook-selected-recipes-container');
if (container) {
container.innerHTML = renderSelectedCookbookRecipeCards();
}
updateCookbookConfirmBtn();
}
function toggleCookbookRecipeChecks(recipeId, val) {
const cbs = document.querySelectorAll(`.cookbook-ing-checkbox[data-recipe-id="${recipeId}"]`);
cbs.forEach(cb => cb.checked = val);
updateCookbookConfirmBtn();
}
function onSelectCookbookDropdown(recipeId) {
if (!recipeId) return;
addCookbookRecipeToSelection(recipeId);
const sel = document.getElementById('cookbook-recipe-select');
if (sel) sel.value = '';
}
function onFilterCookbookSearch(query) {
const resultsDiv = document.getElementById('cookbook-search-dropdown-results');
if (!resultsDiv || !allCookbookRecipes) return;
const q = (query || '').toLowerCase().trim();
if (!q) {
resultsDiv.style.display = 'none';
resultsDiv.innerHTML = '';
return;
}
const matches = allCookbookRecipes.filter(r => {
const titleMatch = (r.title || '').toLowerCase().includes(q);
const subMatch = (r.subtitle || '').toLowerCase().includes(q);
const ingMatch = (r.ingredients || '').toLowerCase().includes(q);
return titleMatch || subMatch || ingMatch;
}).slice(0, 10);
if (matches.length === 0) {
resultsDiv.innerHTML = `<div style="padding: 10px; font-size: 0.88rem; color: #888; text-align: center;">Kein passendes Rezept gefunden</div>`;
resultsDiv.style.display = 'block';
return;
}
resultsDiv.innerHTML = matches.map(r => {
const chName = CHAPTER_NAMES[r.chapter_number] || `Kapitel ${r.chapter_number || 1}`;
return `
<div onclick="selectSearchRecipeMatch(${r.id})" style="padding: 8px 12px; cursor: pointer; border-bottom: 1px solid #eee; display: flex; justify-content: space-between; align-items: center; transition: background 0.15s ease;" onmouseover="this.style.background='rgba(30,77,59,0.08)'" onmouseout="this.style.background='transparent'">
<div>
<strong style="color: var(--shop-green);">${escapeHtml(r.title)}</strong>
${r.subtitle ? `<span style="font-size: 0.8rem; color: #666; margin-left: 6px;">${escapeHtml(r.subtitle)}</span>` : ''}
</div>
<span style="font-size: 0.72rem; color: #888;">${escapeHtml(chName)}</span>
</div>
`;
}).join('');
resultsDiv.style.display = 'block';
}
function selectSearchRecipeMatch(recipeId) {
addCookbookRecipeToSelection(recipeId);
const searchInput = document.getElementById('cookbook-search-input');
if (searchInput) searchInput.value = '';
const resultsDiv = document.getElementById('cookbook-search-dropdown-results');
if (resultsDiv) {
resultsDiv.style.display = 'none';
resultsDiv.innerHTML = '';
}
}
function updateCookbookConfirmBtn() {
const checkedBoxes = Array.from(document.querySelectorAll('.cookbook-ing-checkbox:checked'));
const btn = document.getElementById('btn-confirm-import-cookbook');
if (btn) {
btn.disabled = checkedBoxes.length === 0;
btn.textContent = `Ausgewählte übernehmen (${checkedBoxes.length})`;
}
}
async function confirmImportCookbookIngredients() {
const cbs = Array.from(document.querySelectorAll('.cookbook-ing-checkbox:checked'));
if (cbs.length === 0) return;
const confirmBtn = document.getElementById('btn-confirm-import-cookbook');
if (confirmBtn) {
confirmBtn.disabled = true;
confirmBtn.innerHTML = '<span class="shop-spinner" style="margin-right: 6px;"></span> <span>Wird übernommen...</span>';
}
const itemsToAdd = cbs.map(cb => ({
item_name: cb.dataset.name,
amount: cb.dataset.amount || '',
unit: cb.dataset.unit || '',
source: cb.dataset.source || '',
category: null,
note: ''
}));
try {
const res = await fetch('/api/shopping-list/bulk', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items: itemsToAdd })
});
if (res.ok) {
const d = await res.json();
if (d.items && Array.isArray(d.items)) {
allItems = d.items;
} else {
await refreshDataFromServer();
}
saveLocalCache(allItems);
closeModal('modal-import-cookbook');
renderCategoryFilterChips();
renderShoppingList();
showToast(`✓ ${itemsToAdd.length} Zutaten übernommen (Mengen zusammengeführt)`);
} else {
showToast('Fehler beim Übernehmen');
if (confirmBtn) {
confirmBtn.disabled = false;
confirmBtn.textContent = 'Ausgewählte übernehmen';
}
}
} catch (err) {
console.error('Fehler beim Importieren aus dem Kochbuch:', err);
showToast('Fehler beim Übernehmen');
if (confirmBtn) {
confirmBtn.disabled = false;
confirmBtn.textContent = 'Ausgewählte übernehmen';
}
}
}
// ==========================================
// ==========================================
// MODAL: SUPERMARKT-BEREICHE VERWALTUNG
@@ -2108,6 +2536,16 @@
renderCategoryFilterChips();
renderShoppingList();
initWebSocket();
// Prüfen, ob ein Rezept per URL-Parameter übergeben wurde
const urlParams = new URLSearchParams(window.location.search);
const impRecipeId = urlParams.get('import_recipe_id');
if (impRecipeId) {
window.history.replaceState({}, document.title, window.location.pathname);
setTimeout(() => {
openCookbookImportModal(parseInt(impRecipeId, 10));
}, 120);
}
});
</script>
</body>