Implement supermarket walking path (Laufwege & Regale), presets, and 1-tap shelf reassignment

This commit is contained in:
2026-10-20 13:16:05 +02:00
parent 3709840d7a
commit f7590cc76d
4 changed files with 525 additions and 88 deletions
+297 -30
View File
@@ -250,12 +250,103 @@
background: rgba(30, 77, 59, 0.05);
}
/* Laufweg Schrittnummer & Badges */
.shop-cat-step-num {
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--shop-green);
color: #FFFFFF;
font-family: var(--font-sans);
font-size: 0.72rem;
font-weight: 800;
min-width: 20px;
height: 20px;
padding: 0 4px;
border-radius: 10px;
margin-right: 4px;
flex-shrink: 0;
}
.shop-badge-shelf {
display: inline-flex;
align-items: center;
gap: 3px;
font-size: 0.72rem;
font-weight: 600;
color: var(--shop-green);
background: rgba(30, 77, 59, 0.08);
border: 1px solid rgba(30, 77, 59, 0.2);
border-radius: 4px;
padding: 1px 6px;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
font-family: inherit;
}
.shop-badge-shelf:hover, .shop-badge-shelf:active {
background: rgba(30, 77, 59, 0.18);
border-color: var(--shop-green);
}
.shop-item-row.is-checked .shop-badge-shelf {
opacity: 0.6;
}
.quick-shelf-btn {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 12px;
background: #FFFFFF;
border: 1.5px solid rgba(34, 34, 34, 0.15);
border-radius: 8px;
cursor: pointer;
font-family: inherit;
font-size: 0.95rem;
color: var(--shop-schwarz);
transition: background-color 0.15s, border-color 0.15s;
width: 100%;
}
.quick-shelf-btn:hover, .quick-shelf-btn:active {
background: rgba(30, 77, 59, 0.08);
border-color: var(--shop-green);
}
.quick-shelf-btn.is-current {
background: rgba(30, 77, 59, 0.12);
border-color: var(--shop-green);
font-weight: 700;
}
.shop-preset-bar {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.shop-preset-btn {
flex: 1 1 180px;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 8px 12px;
font-size: 0.85rem;
font-weight: 700;
border-radius: 6px;
border: 1.5px solid var(--shop-green);
background: #FFFFFF;
color: var(--shop-green);
cursor: pointer;
transition: all 0.15s;
font-family: inherit;
}
.shop-preset-btn:hover, .shop-preset-btn:active {
background: var(--shop-green);
color: #FFFFFF;
}
/* Badge nur für persönliche Notiz */
.shop-item-badges {
display: flex;
align-items: center;
gap: 5px;
flex-wrap: wrap;
margin-top: 2px;
}
.shop-badge-note {
font-size: 0.74rem;
@@ -725,6 +816,31 @@
</div>
</div>
<!-- ========================================== -->
<!-- MODAL: SCHNELL-REGALWAHL (1-TAP) -->
<!-- ========================================== -->
<div id="modal-quick-shelf" class="cb-modal-backdrop no-print" onclick="onBackdropClick(event, 'modal-quick-shelf')">
<div class="cb-modal-card" style="max-width: 440px;">
<div class="cb-modal-header">
<h3 class="cb-modal-title"><span>🛒</span> Regal / Bereich wählen</h3>
<button type="button" class="cb-modal-close" onclick="closeModal('modal-quick-shelf')" title="Schließen">&times;</button>
</div>
<div class="cb-modal-body">
<p id="quick-shelf-subtitle" style="font-size: 0.92rem; color: #555; margin: 0 0 12px 0;">
Wohin gehört dieser Artikel im Supermarkt?
</p>
<div id="quick-shelf-options" style="display: flex; flex-direction: column; gap: 6px; max-height: 55vh; overflow-y: auto; padding-right: 2px;">
<!-- Dynamisch befüllt -->
</div>
<div class="modal-btn-row" style="margin-top: 14px;">
<button type="button" class="cb-btn cb-btn-secondary" onclick="closeModal('modal-quick-shelf')">
Abbrechen
</button>
</div>
</div>
</div>
</div>
<!-- ========================================== -->
<!-- MODAL: SUPERMARKT-BEREICHE & LAUFWEG -->
<!-- ========================================== -->
@@ -739,6 +855,21 @@
Ordne die Bereiche mit <strong>▲</strong> und <strong>▼</strong> so an, wie du durch deinen Supermarkt gehst.
</p>
<!-- Presets Bar -->
<div style="margin-bottom: 14px; background: rgba(30, 77, 59, 0.05); border: 1.5px solid rgba(30, 77, 59, 0.15); border-radius: 8px; padding: 10px;">
<div style="font-size: 0.85rem; font-weight: 700; color: var(--shop-green); margin-bottom: 8px;">
Laufweg-Vorlage für deinen Markt:
</div>
<div class="shop-preset-bar">
<button type="button" class="shop-preset-btn" onclick="applyCategoryPreset('standard')" title="Obst/Gemüse ➔ Backwaren ➔ Vorrat ➔ Gewürze ➔ Kühlung ➔ Fleisch ➔ Tiefkühl ➔ Getränke ➔ Drogerie">
🛒 Supermarkt (Edeka / Rewe)
</button>
<button type="button" class="shop-preset-btn" onclick="applyCategoryPreset('discounter')" title="Obst/Gemüse ➔ Backwaren ➔ Kühlwand ➔ Fleisch ➔ Vorrat ➔ Tiefkühl ➔ Getränke ➔ Drogerie">
🏬 Discounter (Aldi / Lidl)
</button>
</div>
</div>
<ul id="cat-manage-list" style="list-style: none; margin: 0 0 16px 0; padding: 0; display: flex; flex-direction: column; gap: 8px;"></ul>
<div style="background: var(--shop-yellow); border: 1.5px solid var(--shop-schwarz); border-radius: 8px; padding: 12px;">
@@ -746,7 +877,24 @@
<form onsubmit="onAddCategory(event)" style="display: flex; flex-direction: column; gap: 8px;">
<div style="display: flex; gap: 8px;">
<input type="text" id="new-cat-icon" style="width: 44px; text-align: center; font-size: 1.2rem; border: 1.5px solid #888; border-radius: 6px;" value="🛒" title="Symbol / Emoji">
<input type="text" id="new-cat-name" placeholder="z. B. Drogerie & Haushalt" class="cb-input" style="flex: 1; padding: 8px 12px;" required>
<input type="text" id="new-cat-name" placeholder="z. B. Gang 3: Nudeln & Soßen" class="cb-input" style="flex: 1; padding: 8px 12px;" required>
</div>
<div>
<label for="new-cat-shelf-type" style="display: block; font-size: 0.78rem; font-weight: 700; color: #444; margin-bottom: 3px;">
Waren-Verhalten (für automatische Zutatenerkennung):
</label>
<select id="new-cat-shelf-type" class="cb-select" style="padding: 6px 10px; font-size: 0.88rem; width: 100%;">
<option value="produce">🥦 Obst, Gemüse & Kräuter</option>
<option value="bakery">🍞 Brot & Backwaren</option>
<option value="pantry" selected>🥫 Vorrat, Nudeln, Reis & Konserven</option>
<option value="spices">🧂 Gewürze, Öle & Essig</option>
<option value="dairy">🧀 Kühlregal, Milchprodukte & Käse</option>
<option value="meat">🥩 Fleisch, Fisch & Wurst</option>
<option value="frozen">❄️ Tiefkühl</option>
<option value="drinks">🧃 Getränke</option>
<option value="drugstore">🧺 Drogerie & Haushalt</option>
<option value="other">🛒 Sonstiges</option>
</select>
</div>
<button type="submit" class="cb-btn cb-btn-green" style="width: 100%; justify-content: center; padding: 9px 14px; font-weight: 700;">+ Bereich hinzufügen</button>
</form>
@@ -754,7 +902,7 @@
<div style="display: flex; justify-content: space-between; align-items: center; margin-top: 14px;">
<button type="button" onclick="resetDefaultCategories()" style="background: none; border: none; font-size: 0.82rem; color: #777; cursor: pointer; text-decoration: underline;">
↺ Standard-Bereiche wiederherstellen
↺ Standard zurücksetzen
</button>
<button type="button" class="cb-btn cb-btn-secondary" onclick="closeModal('modal-categories')">
Fertig
@@ -781,12 +929,15 @@
// Server-Daten & Offline-Cache einbinden
let serverItems = [];
let serverCats = [];
let allShelfTypes = [];
try {
serverItems = {{ items_json | safe }};
serverCats = {{ categories_json | safe }};
allShelfTypes = {{ shelf_types_json | safe }};
} catch (e) {
serverItems = [];
serverCats = [];
allShelfTypes = [];
}
const cachedQueue = JSON.parse(localStorage.getItem('wtc_shop_offline_queue') || '[]');
@@ -1135,21 +1286,24 @@
});
let html = '';
groupNames.forEach(catName => {
groupNames.forEach((catName, groupIdx) => {
const group = groups[catName];
group.open.sort((a, b) => (a.item_name || '').localeCompare(b.item_name || '', 'de', { sensitivity: 'base' }));
group.checked.sort((a, b) => (a.item_name || '').localeCompare(b.item_name || '', 'de', { sensitivity: 'base' }));
const allInGroup = hideChecked ? group.open : [...group.open, ...group.checked];
if (allInGroup.length === 0) return; // Kategorie komplett ausblenden, wenn keine Einträge!
const catObj = allCategories.find(c => c.name === catName) || { name: catName, icon: '🛒' };
const catObj = allCategories.find(c => c.name === catName) || { name: catName, icon: '🛒', sort_order: groupIdx + 1 };
const stepNum = catObj.sort_order ? `${catObj.sort_order}.` : `${groupIdx + 1}.`;
const countBadge = hideChecked ? `${group.open.length} offen` : `${group.open.length} offen / ${allInGroup.length}`;
html += `
<section class="shop-cat-section">
<div class="shop-cat-header">
<h2 class="shop-cat-title">
<span>${catObj.icon || '🛒'}</span> <span>${escapeHtml(catName)}</span>
<span class="shop-cat-step-num">${stepNum}</span>
<span>${catObj.icon || '🛒'}</span>
<span>${escapeHtml(catName)}</span>
</h2>
<span class="shop-cat-count">${countBadge}</span>
</div>
@@ -1166,6 +1320,9 @@
function renderItemRow(item) {
const isChecked = item.checked ? 'is-checked' : '';
const amountStr = formatAmount(item.amount, item.unit);
const catObj = allCategories.find(c => c.name === item.category);
const catIcon = catObj ? (catObj.icon || '🛒') : '🛒';
const catName = item.category || 'Sonstiges';
return `
<li class="shop-item-row ${isChecked}" id="shop-row-${item.id}">
@@ -1180,11 +1337,14 @@
${amountStr ? `<span class="shop-item-amount">${escapeHtml(amountStr)}</span>` : ''}
</div>
${item.note ? `
<div class="shop-item-badges">
<div class="shop-item-badges">
<button type="button" class="shop-badge-shelf" onclick="openQuickShelfPicker(${item.id}, event)" title="Regal / Bereich schnell wechseln (lernt mit)">
<span>${catIcon}</span> <span>${escapeHtml(catName)}</span> <span>▾</span>
</button>
${item.note ? `
<span class="shop-badge-note">📝 ${escapeHtml(item.note)}</span>
</div>
` : ''}
` : ''}
</div>
</div>
<button type="button" class="shop-item-delete" onclick="deleteItemDirectly(${item.id}, event)" title="Eintrag löschen">
@@ -1626,6 +1786,7 @@
}
}
// ==========================================
// ==========================================
// MODAL: SUPERMARKT-BEREICHE VERWALTUNG
// ==========================================
@@ -1638,19 +1799,29 @@
const ul = document.getElementById('cat-manage-list');
if (!ul) return;
ul.innerHTML = allCategories.map((cat, idx) => `
<li style="display: flex; align-items: center; justify-content: space-between; padding: 7px 12px; background: #FFFFFF; border: 1.5px solid rgba(34,34,34,0.15); border-radius: 8px;">
<div style="display: flex; align-items: center; gap: 8px; font-weight: 600;">
<span style="font-size: 1.25rem;">${cat.icon || '🛒'}</span>
<span>${escapeHtml(cat.name)}</span>
</div>
<div style="display: flex; align-items: center; gap: 4px;">
<button type="button" class="cb-btn cb-btn-secondary" style="padding: 2px 7px; font-size: 0.85rem;" onclick="moveCategory(${cat.id}, -1)" ${idx === 0 ? 'disabled style="opacity:0.3;"' : ''}>▲</button>
<button type="button" class="cb-btn cb-btn-secondary" style="padding: 2px 7px; font-size: 0.85rem;" onclick="moveCategory(${cat.id}, 1)" ${idx === allCategories.length - 1 ? 'disabled style="opacity:0.3;"' : ''}>▼</button>
<button type="button" class="cb-btn cb-btn-danger" style="padding: 2px 7px; font-size: 0.85rem; background:#b02a37; border-color:#b02a37; color:#fff;" onclick="deleteCategory(${cat.id})">✕</button>
</div>
</li>
`).join('');
ul.innerHTML = allCategories.map((cat, idx) => {
const stepNum = cat.sort_order || (idx + 1);
const shelfObj = (allShelfTypes || []).find(st => st.type === cat.shelf_type);
const shelfLabel = shelfObj ? shelfObj.label : (cat.shelf_type || 'Sonstiges');
return `
<li style="display: flex; align-items: center; justify-content: space-between; padding: 7px 12px; background: #FFFFFF; border: 1.5px solid rgba(34,34,34,0.15); border-radius: 8px; gap: 8px;">
<div style="display: flex; align-items: center; gap: 8px; font-weight: 600; min-width: 0; flex: 1;">
<span class="shop-cat-step-num">${stepNum}.</span>
<span style="font-size: 1.25rem;">${cat.icon || '🛒'}</span>
<div style="min-width: 0; display: flex; flex-direction: column;">
<span style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${escapeHtml(cat.name)}</span>
<span style="font-size: 0.72rem; color: #777; font-weight: 500;">Typ: ${escapeHtml(shelfLabel)}</span>
</div>
</div>
<div style="display: flex; align-items: center; gap: 4px; flex-shrink: 0;">
<button type="button" class="cb-btn cb-btn-secondary" style="padding: 2px 7px; font-size: 0.85rem;" onclick="moveCategory(${cat.id}, -1)" ${idx === 0 ? 'disabled style="opacity:0.3;"' : ''}>▲</button>
<button type="button" class="cb-btn cb-btn-secondary" style="padding: 2px 7px; font-size: 0.85rem;" onclick="moveCategory(${cat.id}, 1)" ${idx === allCategories.length - 1 ? 'disabled style="opacity:0.3;"' : ''}>▼</button>
<button type="button" class="cb-btn cb-btn-danger" style="padding: 2px 7px; font-size: 0.85rem; background:#b02a37; border-color:#b02a37; color:#fff;" onclick="deleteCategory(${cat.id})">✕</button>
</div>
</li>
`;
}).join('');
}
async function moveCategory(catId, dir) {
@@ -1661,6 +1832,7 @@
const [removed] = allCategories.splice(idx, 1);
allCategories.splice(targetIdx, 0, removed);
allCategories.forEach((c, i) => c.sort_order = i + 1);
renderManageCategoriesList();
renderCategoryFilterChips();
renderShoppingList();
@@ -1679,8 +1851,10 @@
e.preventDefault();
const iconInput = document.getElementById('new-cat-icon');
const nameInput = document.getElementById('new-cat-name');
const shelfSelect = document.getElementById('new-cat-shelf-type');
const name = nameInput.value.trim();
const icon = iconInput.value.trim() || '🛒';
const shelfType = shelfSelect ? shelfSelect.value : 'other';
if (!name) return;
nameInput.value = '';
@@ -1689,7 +1863,12 @@
const res = await fetch('/api/shopping-categories', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: name, icon: icon, sort_order: allCategories.length + 1 })
body: JSON.stringify({
name: name,
icon: icon,
shelf_type: shelfType,
sort_order: allCategories.length + 1
})
});
if (res.ok) {
const d = await res.json();
@@ -1697,6 +1876,7 @@
localStorage.setItem('wtc_shop_cats_cache', JSON.stringify(allCategories));
renderManageCategoriesList();
renderCategoryFilterChips();
renderShoppingList();
showToast(`Bereich "${name}" angelegt`);
}
} catch (e) {}
@@ -1718,20 +1898,106 @@
} catch (e) {}
}
async function resetDefaultCategories() {
if (!confirm('Möchtest du wirklich alle Supermarkt-Bereiche auf den Standard-Laufweg zurücksetzen?')) return;
async function applyCategoryPreset(presetName) {
const label = presetName === 'discounter' ? 'Discounter (Aldi/Lidl)' : 'Supermarkt (Edeka/Rewe)';
if (!confirm(`Möchtest du den Laufweg auf "${label}" umstellen?\nAlle Artikel deiner Liste werden automatisch den neuen Stationen zugeordnet.`)) {
return;
}
try {
const res = await fetch('/api/shopping-categories/reset-defaults', { method: 'POST' });
const res = await fetch(`/api/shopping-categories/reset-defaults?preset=${encodeURIComponent(presetName)}`, {
method: 'POST'
});
if (res.ok) {
const d = await res.json();
allCategories = d.categories || [];
localStorage.setItem('wtc_shop_cats_cache', JSON.stringify(allCategories));
await refreshDataFromServer();
renderManageCategoriesList();
renderCategoryFilterChips();
renderShoppingList();
showToast('Standard-Bereiche wiederhergestellt');
showToast(`✓ Laufweg "${label}" aktiviert`);
}
} catch (e) {}
} catch (e) {
console.error("Fehler beim Aktivieren der Vorlage:", e);
showToast('Fehler beim Aktivieren der Vorlage');
}
}
async function resetDefaultCategories() {
applyCategoryPreset('standard');
}
// ==========================================
// SCHNELL-REGALWAHL (1-TAP PRO ARTIKEL)
// ==========================================
let quickShelfTargetItemId = null;
function openQuickShelfPicker(itemId, event) {
if (event) event.stopPropagation();
const item = allItems.find(it => it.id === itemId);
if (!item) return;
quickShelfTargetItemId = itemId;
const subTitle = document.getElementById('quick-shelf-subtitle');
if (subTitle) {
subTitle.innerHTML = `Regal für <strong>${escapeHtml(item.item_name)}</strong> wählen:`;
}
const container = document.getElementById('quick-shelf-options');
if (container) {
container.innerHTML = allCategories.map((cat, idx) => {
const isCurrent = (item.category || 'Sonstiges') === cat.name;
const stepNum = cat.sort_order || (idx + 1);
return `
<button type="button" class="quick-shelf-btn ${isCurrent ? 'is-current' : ''}" onclick="applyQuickShelfChange('${escapeAttr(cat.name)}')">
<span class="shop-cat-step-num">${stepNum}.</span>
<span style="font-size: 1.25rem;">${cat.icon || '🛒'}</span>
<span style="flex: 1; text-align: left; font-weight: 600;">${escapeHtml(cat.name)}</span>
${isCurrent ? '<span style="font-size: 0.8rem; color: var(--shop-green); font-weight: 700;">✓ Hier</span>' : ''}
</button>
`;
}).join('');
}
openModal('modal-quick-shelf');
}
async function applyQuickShelfChange(newCategory) {
if (!quickShelfTargetItemId) return;
const itemId = quickShelfTargetItemId;
const item = allItems.find(it => it.id === itemId);
closeModal('modal-quick-shelf');
if (item && item.category === newCategory) {
return;
}
// Optimistisches Update
if (item) {
item.category = newCategory;
saveLocalCache(allItems);
renderCategoryFilterChips();
renderShoppingList();
}
showToast(`✓ Zu "${newCategory}" verschoben & gelernt!`);
try {
const res = await fetch(`/api/shopping-list/${itemId}/category`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ category: newCategory })
});
if (!res.ok) throw new Error('Fehler beim Zuweisen der Kategorie');
} catch (err) {
console.warn("Offline oder Fehler bei Kategorieänderung:", err);
pushOfflineQueue({
method: 'POST',
url: `/api/shopping-list/${itemId}/category`,
body: { category: newCategory }
});
}
}
// ==========================================
@@ -1764,9 +2030,10 @@
if (!sel) return;
let html = selectId === 'add-item-category' ? '<option value="">🪄 Automatisch erkennen</option>' : '';
allCategories.forEach(cat => {
allCategories.forEach((cat, idx) => {
const isSel = (cat.name === selectedValue) ? 'selected' : '';
html += `<option value="${escapeAttr(cat.name)}" ${isSel}>${cat.icon || '🛒'} ${escapeHtml(cat.name)}</option>`;
const stepNum = cat.sort_order || (idx + 1);
html += `<option value="${escapeAttr(cat.name)}" ${isSel}>${stepNum}. ${cat.icon || '🛒'} ${escapeHtml(cat.name)}</option>`;
});
if (!allCategories.some(c => c.name === 'Sonstiges')) {