Files
wtc.waiswuaschd.de/app/templates/shopping_list.html
T

1821 lines
67 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>Einkaufsliste – What To Cook?</title>
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#1E4D3B">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="What To Cook?">
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<link rel="alternate icon" type="image/x-icon" href="/favicon.ico">
<link rel="apple-touch-icon" href="/static/apple-touch-icon.png">
<link rel="stylesheet" href="/static/cookbook.css?v=4.6.7">
<style>
:root {
--shop-green: var(--tannengruen, #1E4D3B);
--shop-creme: var(--creme, #FAF6F0);
--shop-schwarz: var(--schwarz, #222222);
--shop-yellow: #FFF9D2;
--shop-red: #D9534F;
}
* {
box-sizing: border-box;
-webkit-tap-highlight-color: transparent;
}
body.shopping-app-body {
margin: 0;
padding: 0;
min-height: 100vh;
min-height: 100dvh;
background-color: var(--shop-creme);
background-image: radial-gradient(rgba(30, 77, 59, 0.05) 1px, transparent 1px);
background-size: 20px 20px;
font-family: var(--font-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif);
color: var(--shop-schwarz);
width: 100%;
}
/* Fester Container für Header + Filterleiste (kleben gemeinsam oben) */
.cb-sticky-header-container {
position: sticky;
top: 0;
z-index: 1000;
width: 100%;
background: var(--shop-green);
}
.cb-nav-btn.is-active {
background: rgba(255, 255, 255, 0.28) !important;
border-color: rgba(255, 255, 255, 0.6) !important;
color: #FFFFFF !important;
font-weight: 700;
}
/* Live Online Badge in Navbar */
.cb-online-pill {
font-size: 0.72rem;
font-weight: 700;
padding: 2px 7px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.2);
color: var(--shop-creme);
border: 1px solid rgba(255, 255, 255, 0.35);
display: inline-flex;
align-items: center;
gap: 4px;
white-space: nowrap;
user-select: none;
}
.cb-online-pill.is-offline {
background: rgba(217, 83, 79, 0.4);
border-color: #D9534F;
color: #FFF;
}
/* Lade-Spinner Animationen */
@keyframes shopSpin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.shop-spinner {
display: inline-block;
width: 15px;
height: 15px;
border: 2px solid rgba(255, 255, 255, 0.35);
border-radius: 50%;
border-top-color: #ffffff;
animation: shopSpin 0.7s linear infinite;
vertical-align: middle;
}
.shop-spinner-green {
display: inline-block;
width: 28px;
height: 28px;
border: 3px solid rgba(30, 77, 59, 0.2);
border-radius: 50%;
border-top-color: var(--shop-green);
animation: shopSpin 0.7s linear infinite;
vertical-align: middle;
}
/* Hauptinhalt Container */
.shop-main-container {
max-width: 820px;
width: 100%;
margin: 0 auto;
padding: 14px 12px 100px 12px;
}
/* Kategorie Sektionen (kompakter & dezenter) */
.shop-cat-section {
margin-bottom: 14px;
}
.shop-cat-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 8px;
margin-bottom: 5px;
background: rgba(30, 77, 59, 0.08);
border-left: 3.5px solid var(--shop-green);
border-radius: 4px 6px 6px 4px;
}
.shop-cat-title {
font-family: var(--font-hand, 'Caveat', cursive);
font-size: 1.15rem;
font-weight: 700;
color: var(--shop-green);
margin: 0;
display: flex;
align-items: center;
gap: 6px;
line-height: 1.1;
}
.shop-cat-count {
font-size: 0.74rem;
font-weight: 700;
color: rgba(34, 34, 34, 0.65);
background: rgba(255, 255, 255, 0.85);
padding: 1px 6px;
border-radius: 8px;
border: 1px solid rgba(34, 34, 34, 0.15);
}
/* Listen-Einträge: Kompakter, lesbar, volle Breite */
.shop-items-list {
display: flex;
flex-direction: column;
gap: 5px;
list-style: none;
padding: 0;
margin: 0;
}
.shop-item-row {
display: flex;
align-items: center;
gap: 10px;
padding: 7px 11px;
background: #FFFFFF;
border: 1.5px solid rgba(34, 34, 34, 0.15);
border-radius: 8px;
box-shadow: 1px 2px 0px rgba(30, 77, 59, 0.06);
transition: background-color 0.15s, opacity 0.15s, transform 0.12s;
position: relative;
}
.shop-item-row:active {
transform: scale(0.995);
}
.shop-item-row.is-checked {
background: rgba(250, 246, 240, 0.65);
border-color: rgba(34, 34, 34, 0.1);
opacity: 0.68;
}
/* Checkbox */
.shop-checkbox-wrap {
flex-shrink: 0;
display: flex;
align-items: center;
cursor: pointer;
}
.shop-checkbox-input {
position: absolute;
opacity: 0;
cursor: pointer;
width: 0;
height: 0;
}
.shop-custom-check {
width: 20px;
height: 20px;
border-radius: 5px;
border: 2px solid var(--shop-green);
background: #FFFFFF;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s ease;
color: transparent;
font-size: 0.85rem;
font-weight: 900;
}
.shop-item-row.is-checked .shop-custom-check {
background: var(--shop-green);
color: #FFFFFF;
border-color: var(--shop-green);
}
/* Textbereich (Klick öffnet Bearbeiten-Modal) */
.shop-item-content {
flex: 1 1 auto;
min-width: 0;
cursor: pointer;
display: flex;
flex-direction: column;
gap: 2px;
}
.shop-item-title-row {
display: flex;
align-items: baseline;
gap: 7px;
flex-wrap: wrap;
word-break: break-word;
}
.shop-item-name {
font-size: 0.94rem;
font-weight: 600;
color: var(--shop-schwarz);
line-height: 1.25;
}
.shop-item-row.is-checked .shop-item-name {
text-decoration: line-through;
color: rgba(34, 34, 34, 0.5);
}
.shop-item-amount {
font-size: 0.84rem;
font-weight: 700;
color: var(--shop-green);
background: rgba(30, 77, 59, 0.1);
padding: 1px 6px;
border-radius: 5px;
white-space: nowrap;
}
.shop-item-row.is-checked .shop-item-amount {
color: rgba(30, 77, 59, 0.5);
background: rgba(30, 77, 59, 0.05);
}
/* Badge nur für persönliche Notiz */
.shop-item-badges {
display: flex;
align-items: center;
gap: 5px;
flex-wrap: wrap;
}
.shop-badge-note {
font-size: 0.74rem;
color: #7a5a00;
background: var(--shop-yellow);
border: 1px solid #d4c165;
padding: 1px 5px;
border-radius: 4px;
display: inline-flex;
align-items: center;
gap: 3px;
}
/* Löschen-Icon am rechten Rand */
.shop-item-delete {
flex-shrink: 0;
background: none;
border: none;
font-size: 1rem;
color: rgba(34, 34, 34, 0.35);
cursor: pointer;
padding: 5px 6px;
border-radius: 6px;
transition: all 0.15s;
display: flex;
align-items: center;
justify-content: center;
}
.shop-item-delete:hover,
.shop-item-delete:active {
color: var(--shop-red);
background: rgba(217, 83, 79, 0.1);
}
/* Modal Form: Labels immer direkt über Input-Feld */
.modal-form-grid {
display: flex;
flex-direction: column;
gap: 12px;
}
.modal-form-grid > div {
display: flex;
flex-direction: column;
align-items: flex-start;
width: 100%;
}
.modal-form-grid .cb-label {
display: block;
font-family: var(--font-hand, 'Caveat', cursive);
font-size: 1.15rem;
font-weight: 700;
color: var(--shop-schwarz);
margin: 0 0 3px 0;
text-align: left;
}
.modal-form-grid .cb-input,
.modal-form-grid .cb-select {
width: 100%;
box-sizing: border-box;
padding: 10px 12px;
font-size: 0.95rem;
}
/* Floating Action Button (FAB '+') */
.cb-fab {
position: fixed;
bottom: 24px;
right: 24px;
width: 58px;
height: 58px;
border-radius: 50%;
background: var(--shop-green);
color: var(--shop-creme);
border: 2.5px solid var(--shop-schwarz);
box-shadow: 3px 5px 12px rgba(0, 0, 0, 0.35);
font-size: 2.2rem;
font-family: var(--font-hand, 'Caveat', cursive);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 950;
transition: all 0.18s ease;
line-height: 1;
padding-bottom: 4px;
}
.cb-fab:hover {
transform: scale(1.08) translateY(-2px);
background: #143729;
}
.cb-fab:active {
transform: scale(0.95);
box-shadow: 2px 2px 6px rgba(0, 0, 0, 0.3);
}
/* Leerer Zustand */
.shop-empty-state {
text-align: center;
padding: 50px 20px;
background: rgba(255, 255, 255, 0.85);
border: 2px dashed rgba(30, 77, 59, 0.3);
border-radius: 12px;
margin: 30px auto;
max-width: 480px;
}
.shop-empty-icon {
font-size: 3.5rem;
margin-bottom: 12px;
}
.shop-empty-title {
font-family: var(--font-hand, 'Caveat', cursive);
font-size: 2.2rem;
color: var(--shop-green);
margin: 0 0 8px 0;
}
.shop-empty-desc {
font-size: 0.98rem;
color: #555;
margin: 0 0 20px 0;
line-height: 1.4;
}
/* Modal Form Ergänzungen */
.modal-form-grid {
display: flex;
flex-direction: column;
gap: 14px;
}
.modal-btn-row {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 18px;
}
/* Toast Meldung */
.shop-toast {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%) translateY(100px);
background: var(--shop-green);
color: #FFFFFF;
padding: 10px 20px;
border-radius: 30px;
font-weight: 700;
font-size: 0.95rem;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.3);
z-index: 3000;
transition: transform 0.25s ease-out;
pointer-events: none;
}
.shop-toast.show {
transform: translateX(-50%) translateY(0);
}
/* Safe Area Inset für Mobile */
@supports (padding-bottom: env(safe-area-inset-bottom)) {
.cb-fab {
bottom: calc(24px + env(safe-area-inset-bottom));
}
.shop-main-container {
padding-bottom: calc(100px + env(safe-area-inset-bottom));
}
}
</style>
</head>
<body class="shopping-app-body">
<!-- ============================================================== -->
<!-- FIXIERTER KOPFBEREICH: HEADER & KATEGORIE-FILTERLEISTE GEMEINSAM -->
<!-- ============================================================== -->
<div class="cb-sticky-header-container">
<header class="cb-nav">
<a href="/shopping-list" class="cb-nav-brand" title="Einkaufsliste neu laden">
<span class="cb-brand-icon">🛒</span>
<h1 class="cb-nav-title">Einkaufsliste</h1>
</a>
<!-- Rechte Navigation & Status -->
<div style="display: flex; align-items: center; gap: 8px; margin-left: auto;">
<span id="shop-online-pill" class="cb-online-pill" title="Synchronisations-Status">● Live</span>
<!-- Desktop Buttons (auf Bildschirmen > 768px) -->
<div class="cb-nav-actions desktop-nav-actions">
<button type="button" id="btn-toggle-hide-checked" onclick="toggleHideChecked()" class="cb-nav-btn" title="Erledigte Artikel ausblenden / einblenden">
<span id="hide-checked-icon">👁️</span> <span id="hide-checked-text">Erledigte ausblenden</span>
</button>
<button type="button" onclick="openCategoriesModal()" class="cb-nav-btn" title="Supermarkt-Bereiche & Laufweg anpassen">
<span>🏬</span> <span>Bereiche anpassen</span>
</button>
<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="deleteCheckedItems()" class="cb-nav-btn" title="Erledigte Artikel löschen">
<span>🧹</span> <span>Erledigte löschen</span>
</button>
<button type="button" onclick="openDeleteStep1Modal()" class="cb-nav-btn cb-btn-danger" style="background:#b02a37; border-color:#b02a37; color:#fff;" title="Liste leeren">
<span>🗑️</span> <span>Liste leeren</span>
</button>
<a href="/" class="cb-nav-btn" title="Zur Startseite">
<span>🏠</span> <span>Startseite</span>
</a>
<a href="/cookbook" class="cb-nav-btn" title="Kochbuch & Rezepte">
<span>📖</span> <span>Kochbuch</span>
</a>
<a href="/logout" class="cb-nav-btn cb-nav-btn-logout" title="Abmelden">
<span>🚪</span> <span>Abmelden</span>
</a>
</div>
<!-- Mobile 3-Punkte (Kebab) Menü (auf Bildschirmen <= 768px) -->
<div class="mobile-nav-wrapper">
<button type="button" class="cb-kebab-btn" onclick="toggleMobileMenu(event)" aria-label="Menü öffnen" title="Menü">
<span>⋮</span>
</button>
<div id="cb-mobile-menu" class="cb-mobile-dropdown">
<button type="button" onclick="handleMobileMenuAction(toggleHideChecked)" class="cb-mobile-item">
<span id="hide-checked-mobile-icon">👁️</span> <span id="hide-checked-mobile-text">Erledigte ausblenden</span>
</button>
<button type="button" onclick="handleMobileMenuAction(openCategoriesModal)" class="cb-mobile-item">
<span>🏬</span> <span>Bereiche anpassen</span>
</button>
<button type="button" onclick="handleMobileMenuAction(openMealPlanImportModal)" class="cb-mobile-item">
<span>📅</span> <span>Aus Wochenplan holen</span>
</button>
<button type="button" onclick="handleMobileMenuAction(deleteCheckedItems)" class="cb-mobile-item">
<span>🧹</span> <span>Erledigte löschen</span>
</button>
<hr class="cb-mobile-divider">
<button type="button" onclick="handleMobileMenuAction(openDeleteStep1Modal)" class="cb-mobile-item" style="color: #b02a37; font-weight: 700;">
<span>🗑️</span> <span>Liste leeren</span>
</button>
<hr class="cb-mobile-divider">
<a href="/" class="cb-mobile-item">
<span>🏠</span> <span>Startseite</span>
</a>
<a href="/cookbook" class="cb-mobile-item">
<span>📖</span> <span>Kochbuch</span>
</a>
<hr class="cb-mobile-divider">
<a href="/logout" class="cb-mobile-item cb-mobile-logout">
<span>🚪</span> <span>Abmelden</span>
</a>
</div>
</div>
</div>
</header>
<!-- DAUERHAFT FIXIERTE KATEGORIEN-LEISTE -->
<nav class="cb-filter-bar no-print" id="shop-category-bar">
<div class="cb-filter-scroll-container" id="shop-category-chips">
<!-- Dynamisch befüllt mit Kategorien -->
</div>
</nav>
</div>
<!-- ========================================== -->
<!-- HAUPTINHALT: EINKAUFSLISTEN-EINTRÄGE -->
<!-- ========================================== -->
<main class="shop-main-container">
<div id="items-container">
<!-- Wird dynamisch aus JavaScript gerendert -->
</div>
<!-- Leerer Zustand -->
<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>
</div>
</main>
<!-- Floating Action Button (FAB '+') -->
<button type="button" class="cb-fab" onclick="openAddItemModal()" title="Neuen Eintrag hinzufügen">
<span>+</span>
</button>
<!-- Toast Notification -->
<div id="shop-toast" class="shop-toast">✓ Gespeichert</div>
<!-- ========================================== -->
<!-- MODAL: EINTRAG BEARBEITEN -->
<!-- ========================================== -->
<div id="modal-edit-item" class="cb-modal-backdrop no-print" onclick="onBackdropClick(event, 'modal-edit-item')">
<div class="cb-modal-card" style="max-width: 500px;">
<div class="cb-modal-header">
<h3 class="cb-modal-title"><span>✏️</span> Eintrag bearbeiten</h3>
<button type="button" class="cb-modal-close" onclick="closeModal('modal-edit-item')" title="Schließen">&times;</button>
</div>
<form onsubmit="onSaveEditItem(event)">
<div class="cb-modal-body">
<input type="hidden" id="edit-item-id">
<div class="modal-form-grid">
<div>
<label class="cb-label" for="edit-item-name">Artikel / Zutat *</label>
<input type="text" id="edit-item-name" class="cb-input" required autocomplete="off">
</div>
<div>
<label class="cb-label" for="edit-item-category">Bereich im Supermarkt</label>
<select id="edit-item-category" class="cb-select"></select>
</div>
<div>
<label class="cb-label" for="edit-item-amount">Menge</label>
<input type="text" id="edit-item-amount" class="cb-input" placeholder="z. B. 2 oder 500">
</div>
<div>
<label class="cb-label" for="edit-item-unit">Einheit</label>
<input type="text" id="edit-item-unit" class="cb-input" placeholder="z. B. g, l, Bund, Dose">
</div>
<div>
<label class="cb-label" for="edit-item-note">Notiz / Extra</label>
<input type="text" id="edit-item-note" class="cb-input" placeholder="z. B. Bio, laktosefrei, im Angebot">
</div>
</div>
<div class="modal-btn-row">
<button type="button" class="cb-btn cb-btn-danger" onclick="onDeleteItemFromModal()" style="margin-right: auto; background: #b02a37; border-color: #b02a37; color: #fff;">
🗑️ Löschen
</button>
<button type="button" class="cb-btn cb-btn-secondary" onclick="closeModal('modal-edit-item')">
Schließen
</button>
<button type="submit" class="cb-btn cb-btn-green">
Speichern
</button>
</div>
</div>
</form>
</div>
</div>
<!-- ========================================== -->
<!-- MODAL: NEUER EINTRAG (FAB '+') -->
<!-- ========================================== -->
<div id="modal-add-item" class="cb-modal-backdrop no-print" onclick="onBackdropClick(event, 'modal-add-item')">
<div class="cb-modal-card" style="max-width: 500px;">
<div class="cb-modal-header">
<h3 class="cb-modal-title"><span>➕</span> Neuer Eintrag</h3>
<button type="button" class="cb-modal-close" onclick="closeModal('modal-add-item')" title="Schließen">&times;</button>
</div>
<form onsubmit="onSaveAddItem(event)">
<div class="cb-modal-body">
<div class="modal-form-grid">
<div>
<label class="cb-label" for="add-item-name">Artikel / Zutat *</label>
<input type="text" id="add-item-name" class="cb-input" placeholder="z. B. Gurke oder 500g Quark..." required autocomplete="off">
</div>
<div>
<label class="cb-label" for="add-item-category">Bereich im Supermarkt</label>
<select id="add-item-category" class="cb-select">
<option value="">🪄 Automatisch erkennen</option>
</select>
</div>
<div>
<label class="cb-label" for="add-item-amount">Menge</label>
<input type="text" id="add-item-amount" class="cb-input" placeholder="z. B. 2 oder 500">
</div>
<div>
<label class="cb-label" for="add-item-unit">Einheit</label>
<input type="text" id="add-item-unit" class="cb-input" placeholder="z. B. g, l, Bund, Dose">
</div>
<div>
<label class="cb-label" for="add-item-note">Notiz / Extra</label>
<input type="text" id="add-item-note" class="cb-input" placeholder="z. B. Bio, im Angebot">
</div>
</div>
<div class="modal-btn-row">
<button type="button" class="cb-btn cb-btn-secondary" onclick="closeModal('modal-add-item')">
Schließen
</button>
<button type="submit" class="cb-btn cb-btn-green">
Hinzufügen
</button>
</div>
</div>
</form>
</div>
</div>
<!-- ========================================== -->
<!-- MODAL: 2-STUFEN LÖSCHEN -->
<!-- ========================================== -->
<!-- Stufe 1: Einkaufsliste leeren? -->
<div id="modal-delete-step1" class="cb-modal-backdrop no-print" onclick="onBackdropClick(event, 'modal-delete-step1')">
<div class="cb-modal-card" style="max-width: 440px;">
<div class="cb-modal-header" style="background: #b02a37;">
<h3 class="cb-modal-title"><span>🗑️</span> Einkaufsliste leeren?</h3>
<button type="button" class="cb-modal-close" onclick="closeModal('modal-delete-step1')" title="Schließen">&times;</button>
</div>
<div class="cb-modal-body">
<p style="margin: 0 0 20px 0; font-size: 1.05rem; line-height: 1.45;">
Möchtest du wirklich alle Artikel von der Einkaufsliste entfernen?
</p>
<div class="modal-btn-row">
<button type="button" class="cb-btn cb-btn-secondary" onclick="closeModal('modal-delete-step1')">
Schließen
</button>
<button type="button" class="cb-btn cb-btn-danger" onclick="openDeleteStep2Modal()" style="background: #b02a37; border-color: #b02a37; color: #fff;">
Löschen
</button>
</div>
</div>
</div>
</div>
<!-- Stufe 2: Wirklich alles löschen? -->
<div id="modal-delete-step2" class="cb-modal-backdrop no-print" onclick="onBackdropClick(event, 'modal-delete-step2')">
<div class="cb-modal-card" style="max-width: 440px; border-color: #800;">
<div class="cb-modal-header" style="background: #800;">
<h3 class="cb-modal-title"><span>⚠️</span> Wirklich alles löschen?</h3>
<button type="button" class="cb-modal-close" onclick="closeModal('modal-delete-step2')" title="Schließen">&times;</button>
</div>
<div class="cb-modal-body">
<p style="margin: 0 0 20px 0; font-size: 1.05rem; line-height: 1.45; color: #800; font-weight: 700;">
Achtung: Alle Einträge (auch noch nicht erledigte Artikel) werden unwiderruflich gelöscht!
</p>
<div class="modal-btn-row">
<button type="button" class="cb-btn cb-btn-secondary" onclick="closeModal('modal-delete-step2')">
Abbrechen
</button>
<button type="button" class="cb-btn cb-btn-danger" onclick="confirmClearAllItems()" style="background: #800; border-color: #800; color: #fff;">
Ja, wirklich alles löschen
</button>
</div>
</div>
</div>
</div>
<!-- ========================================== -->
<!-- MODAL: WOCHENPLAN ZUTATEN IMPORTIEREN -->
<!-- ========================================== -->
<div id="modal-import-mealplan" class="cb-modal-backdrop no-print" onclick="onBackdropClick(event, 'modal-import-mealplan')">
<div class="cb-modal-card" style="max-width: 580px;">
<div class="cb-modal-header">
<h3 class="cb-modal-title"><span>📅</span> Zutaten aus Wochenplan holen</h3>
<button type="button" class="cb-modal-close" onclick="closeModal('modal-import-mealplan')" title="Schließen">&times;</button>
</div>
<div class="cb-modal-body">
<div id="mealplan-import-body" style="min-height: 120px;">
<div style="text-align: center; padding: 25px; color: #666;">
<span>⏳ Zutaten des aktuellen Wochenplans werden geladen...</span>
</div>
</div>
<div class="modal-btn-row">
<button type="button" class="cb-btn cb-btn-secondary" onclick="closeModal('modal-import-mealplan')">
Schließen
</button>
<button type="button" class="cb-btn cb-btn-green" id="btn-confirm-import-mealplan" onclick="confirmImportMealPlanIngredients()" disabled>
Ausgewählte übernehmen
</button>
</div>
</div>
</div>
</div>
<!-- ========================================== -->
<!-- MODAL: SUPERMARKT-BEREICHE & LAUFWEG -->
<!-- ========================================== -->
<div id="modal-categories" class="cb-modal-backdrop no-print" onclick="onBackdropClick(event, 'modal-categories')">
<div class="cb-modal-card" style="max-width: 540px;">
<div class="cb-modal-header">
<h3 class="cb-modal-title"><span>🏬</span> Supermarkt-Bereiche anpassen</h3>
<button type="button" class="cb-modal-close" onclick="closeModal('modal-categories')" title="Schließen">&times;</button>
</div>
<div class="cb-modal-body">
<p style="font-size: 0.9rem; color: #555; margin: 0 0 12px 0;">
Ordne die Bereiche mit <strong>▲</strong> und <strong>▼</strong> so an, wie du durch deinen Supermarkt gehst.
</p>
<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;">
<h4 style="margin: 0 0 8px 0; font-size: 0.95rem;">Neuen Bereich anlegen</h4>
<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>
</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>
</div>
<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
</button>
<button type="button" class="cb-btn cb-btn-secondary" onclick="closeModal('modal-categories')">
Fertig
</button>
</div>
</div>
</div>
</div>
<!-- ========================================== -->
<!-- JAVASCRIPT: LOGIK, WEBSOCKET & OFFLINE-SYNC-->
<!-- ========================================== -->
<script>
// State Initialisierung aus Server oder localStorage
let allItems = [];
let allCategories = [];
let currentFilterCategory = null;
let hideChecked = localStorage.getItem('wtc_hide_checked') === 'true';
let ws = null;
let reconnectTimeout = null;
let pingInterval = null;
let isSyncingQueue = false;
// Server-Daten & Offline-Cache einbinden
let serverItems = [];
let serverCats = [];
try {
serverItems = {{ items_json | safe }};
serverCats = {{ categories_json | safe }};
} catch (e) {
serverItems = [];
serverCats = [];
}
const cachedQueue = JSON.parse(localStorage.getItem('wtc_shop_offline_queue') || '[]');
const cachedItems = JSON.parse(localStorage.getItem('wtc_shop_items_cache') || 'null');
const cachedCats = JSON.parse(localStorage.getItem('wtc_shop_cats_cache') || 'null');
// Wenn offline ODER noch ungesendete Offline-Änderungen vorliegen: Lokalen Cache priorisieren!
if (!navigator.onLine || cachedQueue.length > 0) {
allItems = (cachedItems !== null) ? cachedItems : (serverItems || []);
allCategories = (cachedCats !== null) ? cachedCats : (serverCats || []);
} else {
allItems = (serverItems && serverItems.length) ? serverItems : (cachedItems || []);
allCategories = (serverCats && serverCats.length) ? serverCats : (cachedCats || []);
saveLocalCache(allItems);
localStorage.setItem('wtc_shop_cats_cache', JSON.stringify(allCategories));
}
function saveLocalCache(items) {
allItems = items;
try {
localStorage.setItem('wtc_shop_items_cache', JSON.stringify(items));
} catch (e) {
console.warn('LocalStorage voll oder nicht verfügbar:', e);
}
}
// ==========================================
// MOBILE NAVIGATION (3-PUNKTE-MENÜ)
// ==========================================
function toggleMobileMenu(e) {
if (e) e.stopPropagation();
const menu = document.getElementById('cb-mobile-menu');
if (menu) menu.classList.toggle('is-open');
}
function handleMobileMenuAction(fn) {
const menu = document.getElementById('cb-mobile-menu');
if (menu) menu.classList.remove('is-open');
if (fn) fn();
}
document.addEventListener('click', (e) => {
if (!e.target.closest('.mobile-nav-wrapper')) {
const menu = document.getElementById('cb-mobile-menu');
if (menu) menu.classList.remove('is-open');
}
});
// ==========================================
// WEBSOCKET & LIVE SYNCHRONISATION
// ==========================================
function initWebSocket() {
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
return;
}
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/ws/shopping-list`;
try {
ws = new WebSocket(wsUrl);
ws.onopen = () => {
updateOnlineStatus(true);
if (pingInterval) clearInterval(pingInterval);
pingInterval = setInterval(() => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send('ping');
}
}, 25000);
};
ws.onmessage = (event) => {
if (event.data === 'pong') return;
try {
const data = JSON.parse(event.data);
if (data.action === 'items_updated') {
if (isSyncingQueue) return;
allItems = data.items;
saveLocalCache(allItems);
renderShoppingList();
} else if (data.action === 'categories_updated') {
if (isSyncingQueue) return;
allCategories = data.categories;
if (data.items) {
allItems = data.items;
saveLocalCache(allItems);
}
localStorage.setItem('wtc_shop_cats_cache', JSON.stringify(allCategories));
renderCategoryFilterChips();
renderShoppingList();
}
} catch (err) {
console.error("Fehler beim Verarbeiten der WebSocket-Nachricht:", err);
}
};
ws.onclose = () => {
if (pingInterval) clearInterval(pingInterval);
updateOnlineStatus(false);
scheduleReconnect();
};
ws.onerror = () => {
if (ws) ws.close();
};
} catch (e) {
updateOnlineStatus(false);
scheduleReconnect();
}
}
function scheduleReconnect() {
if (reconnectTimeout) clearTimeout(reconnectTimeout);
reconnectTimeout = setTimeout(initWebSocket, 3000);
}
function updateOnlineStatus(isOnline) {
const pill = document.getElementById('shop-online-pill');
if (!pill) return;
if (isOnline) {
pill.textContent = '● Live';
pill.classList.remove('is-offline');
} else {
pill.textContent = '⚡ Offline';
pill.classList.add('is-offline');
}
}
window.addEventListener('online', () => {
updateOnlineStatus(true);
initWebSocket();
flushOfflineQueue();
});
window.addEventListener('offline', () => {
updateOnlineStatus(false);
});
// ==========================================
// OFFLINE MUTATION QUEUE
// ==========================================
function getOfflineQueue() {
return JSON.parse(localStorage.getItem('wtc_shop_offline_queue') || '[]');
}
function pushOfflineQueue(action) {
const q = getOfflineQueue();
q.push(action);
localStorage.setItem('wtc_shop_offline_queue', JSON.stringify(q));
}
async function flushOfflineQueue() {
if (isSyncingQueue) return;
const q = getOfflineQueue();
if (!q.length) return;
isSyncingQueue = true;
try {
for (const act of q) {
try {
await fetch(act.url, {
method: act.method,
headers: { 'Content-Type': 'application/json' },
body: act.body ? JSON.stringify(act.body) : undefined
});
} catch (e) {
console.warn("Fehler beim Nachholen einer Offline-Aktion:", e);
}
}
localStorage.removeItem('wtc_shop_offline_queue');
await refreshDataFromServer();
showToast('✓ Offline-Änderungen synchronisiert');
} finally {
isSyncingQueue = false;
}
}
async function refreshDataFromServer() {
try {
const [itemsRes, catsRes] = await Promise.all([
fetch('/api/shopping-list'),
fetch('/api/shopping-categories')
]);
if (itemsRes.ok) {
const d = await itemsRes.json();
allItems = d.items || [];
localStorage.setItem('wtc_shop_items_cache', JSON.stringify(allItems));
}
if (catsRes.ok) {
const d = await catsRes.json();
allCategories = d.categories || [];
localStorage.setItem('wtc_shop_cats_cache', JSON.stringify(allCategories));
}
renderCategoryFilterChips();
renderShoppingList();
} catch (e) {
console.warn("Konnte Daten nicht vom Server aktualisieren:", e);
}
}
// ==========================================
// TOGGLE: ERLEDIGTE AUSBLENDEN / EINBLENDEN
// ==========================================
function toggleHideChecked() {
hideChecked = !hideChecked;
localStorage.setItem('wtc_hide_checked', hideChecked ? 'true' : 'false');
updateHideCheckedUI();
renderCategoryFilterChips();
renderShoppingList();
}
function updateHideCheckedUI() {
const desktopBtn = document.getElementById('btn-toggle-hide-checked');
const desktopIcon = document.getElementById('hide-checked-icon');
const desktopText = document.getElementById('hide-checked-text');
const mobileIcon = document.getElementById('hide-checked-mobile-icon');
const mobileText = document.getElementById('hide-checked-mobile-text');
if (hideChecked) {
if (desktopBtn) desktopBtn.classList.add('is-active');
if (desktopIcon) desktopIcon.textContent = '👁️🗨️';
if (desktopText) desktopText.textContent = 'Erledigte anzeigen';
if (mobileIcon) mobileIcon.textContent = '👁️🗨️';
if (mobileText) mobileText.textContent = 'Erledigte anzeigen';
} else {
if (desktopBtn) desktopBtn.classList.remove('is-active');
if (desktopIcon) desktopIcon.textContent = '👁️';
if (desktopText) desktopText.textContent = 'Erledigte ausblenden';
if (mobileIcon) mobileIcon.textContent = '👁️';
if (mobileText) mobileText.textContent = 'Erledigte ausblenden';
}
}
// ==========================================
// RENDERING: KATEGORIEN-FILTERLEISTE
// ==========================================
function renderCategoryFilterChips() {
const container = document.getElementById('shop-category-chips');
if (!container) return;
const relevantItems = hideChecked ? allItems.filter(it => !it.checked) : allItems;
const totalItems = relevantItems.length;
let html = `
<button type="button" class="cb-chip ${currentFilterCategory === null ? 'active' : ''}" onclick="selectFilterCategory(null)">
Alle (${totalItems})
</button>
`;
allCategories.forEach(cat => {
const count = relevantItems.filter(it => it.category === cat.name).length;
if (count > 0 || currentFilterCategory === cat.name) {
const isAct = currentFilterCategory === cat.name;
html += `
<button type="button" class="cb-chip ${isAct ? 'active' : ''}" onclick="selectFilterCategory('${escapeAttr(cat.name)}')">
<span>${cat.icon || '🛒'}</span> <span>${escapeHtml(cat.name)}</span> <span>(${count})</span>
</button>
`;
}
});
// 'Sonstiges' anzeigen falls vorhanden
const uncategorizedCount = relevantItems.filter(it => !it.category || it.category === 'Sonstiges').length;
if (uncategorizedCount > 0 && !allCategories.some(c => c.name === 'Sonstiges')) {
const isAct = currentFilterCategory === 'Sonstiges';
html += `
<button type="button" class="cb-chip ${isAct ? 'active' : ''}" onclick="selectFilterCategory('Sonstiges')">
<span>🛒</span> <span>Sonstiges</span> <span>(${uncategorizedCount})</span>
</button>
`;
}
container.innerHTML = html;
}
function selectFilterCategory(catName) {
currentFilterCategory = catName;
renderCategoryFilterChips();
renderShoppingList();
}
// ==========================================
// RENDERING: EINKAUFSLISTEN-ITEMS (LAUFWEG)
// ==========================================
function renderShoppingList() {
const container = document.getElementById('items-container');
const emptyState = document.getElementById('empty-state');
if (!container) return;
// Filter anwenden
let filtered = allItems;
if (currentFilterCategory !== null) {
filtered = filtered.filter(it => (it.category || 'Sonstiges') === currentFilterCategory);
}
if (hideChecked) {
filtered = filtered.filter(it => !it.checked);
}
if (filtered.length === 0) {
container.innerHTML = '';
if (emptyState) {
emptyState.style.display = 'block';
const totalOpen = allItems.filter(it => !it.checked).length;
const titleEl = emptyState.querySelector('.shop-empty-title');
const descEl = emptyState.querySelector('.shop-empty-desc');
if (hideChecked && allItems.length > 0 && totalOpen === 0) {
if (titleEl) titleEl.textContent = 'Alle offenen Artikel erledigt!';
if (descEl) descEl.innerHTML = 'Alle Artikel auf deiner Liste sind abgehakt und ausgeblendet. Klicke auf <strong>Erledigte anzeigen</strong>, um sie wieder einzublenden.';
} else {
if (titleEl) titleEl.textContent = 'Alles eingekauft!';
if (descEl) descEl.innerHTML = 'Deine Einkaufsliste ist leer. Tippe unten rechts auf das <strong>+</strong>, um einen neuen Eintrag zu notieren, oder übernimm Zutaten direkt aus dem Wochenplan.';
}
}
return;
} else {
if (emptyState) emptyState.style.display = 'none';
}
renderCategorizedList(container, filtered);
}
function renderCategorizedList(container, items) {
// 1. Kategorien nach sort_order sortieren
const catOrderMap = {};
allCategories.forEach((c, idx) => {
catOrderMap[c.name] = (c.sort_order !== null && c.sort_order !== undefined) ? c.sort_order : idx;
});
// 2. Items in Gruppen aufteilen (offen vs. abgehakt)
const groups = {};
items.forEach(it => {
const cat = it.category || 'Sonstiges';
if (!groups[cat]) groups[cat] = { open: [], checked: [] };
if (it.checked) {
groups[cat].checked.push(it);
} else {
groups[cat].open.push(it);
}
});
// 3. Gruppen-Reihenfolge bestimmen
const groupNames = Object.keys(groups).sort((a, b) => {
const orderA = catOrderMap[a] !== undefined ? catOrderMap[a] : 999;
const orderB = catOrderMap[b] !== undefined ? catOrderMap[b] : 999;
return orderA - orderB;
});
let html = '';
groupNames.forEach(catName => {
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 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>
</h2>
<span class="shop-cat-count">${countBadge}</span>
</div>
<ul class="shop-items-list">
${allInGroup.map(item => renderItemRow(item)).join('')}
</ul>
</section>
`;
});
container.innerHTML = html;
}
function renderItemRow(item) {
const isChecked = item.checked ? 'is-checked' : '';
const amountStr = formatAmount(item.amount, item.unit);
return `
<li class="shop-item-row ${isChecked}" id="shop-row-${item.id}">
<label class="shop-checkbox-wrap" onclick="event.stopPropagation();">
<input type="checkbox" class="shop-checkbox-input" ${item.checked ? 'checked' : ''} onchange="toggleItemCheck(${item.id})">
<span class="shop-custom-check">✓</span>
</label>
<div class="shop-item-content" onclick="openEditModalById(${item.id})" title="Klicken zum Bearbeiten">
<div class="shop-item-title-row">
<span class="shop-item-name">${escapeHtml(item.item_name)}</span>
${amountStr ? `<span class="shop-item-amount">${escapeHtml(amountStr)}</span>` : ''}
</div>
${item.note ? `
<div class="shop-item-badges">
<span class="shop-badge-note">📝 ${escapeHtml(item.note)}</span>
</div>
` : ''}
</div>
<button type="button" class="shop-item-delete" onclick="deleteItemDirectly(${item.id}, event)" title="Eintrag löschen">
🗑️
</button>
</li>
`;
}
function formatAmount(amount, unit) {
if (!amount && !unit) return '';
if (amount && unit) return `${amount} ${unit}`;
return amount || unit;
}
// ==========================================
// AKTIONEN: TOGGLE, ADD, EDIT, DELETE
// ==========================================
async function toggleItemCheck(itemId) {
const item = allItems.find(it => it.id === itemId);
if (!item) return;
// Optimistic UI Update
item.checked = !item.checked;
saveLocalCache(allItems);
renderCategoryFilterChips();
renderShoppingList();
try {
const res = await fetch(`/api/shopping-list/${itemId}/toggle?checked=${item.checked}`, { method: 'POST' });
if (!res.ok) throw new Error('Fehler beim Abhaken');
} catch (err) {
pushOfflineQueue({ method: 'POST', url: `/api/shopping-list/${itemId}/toggle?checked=${item.checked}` });
}
}
async function deleteItemDirectly(itemId, event) {
if (event) event.stopPropagation();
// Optimistic remove
allItems = allItems.filter(it => it.id !== itemId);
saveLocalCache(allItems);
renderShoppingList();
showToast('Eintrag gelöscht');
try {
const res = await fetch(`/api/shopping-list/${itemId}`, { method: 'DELETE' });
if (!res.ok) throw new Error('Fehler beim Löschen');
} catch (err) {
pushOfflineQueue({ method: 'DELETE', url: `/api/shopping-list/${itemId}` });
}
}
async function deleteCheckedItems() {
const checkedCount = allItems.filter(it => it.checked).length;
if (checkedCount === 0) {
showToast('Keine erledigten Artikel vorhanden');
return;
}
allItems = allItems.filter(it => !it.checked);
saveLocalCache(allItems);
renderShoppingList();
showToast(`${checkedCount} erledigte Artikel gelöscht`);
try {
await fetch('/api/shopping-list/checked', { method: 'DELETE' });
} catch (err) {
pushOfflineQueue({ method: 'DELETE', url: '/api/shopping-list/checked' });
}
}
// ==========================================
// MODAL: NEUER EINTRAG (ADD)
// ==========================================
function openAddItemModal() {
populateCategorySelect('add-item-category', '');
document.getElementById('add-item-name').value = '';
document.getElementById('add-item-amount').value = '';
document.getElementById('add-item-unit').value = '';
document.getElementById('add-item-note').value = '';
openModal('modal-add-item');
setTimeout(() => document.getElementById('add-item-name').focus(), 100);
}
async function onSaveAddItem(e) {
e.preventDefault();
const name = document.getElementById('add-item-name').value.trim();
if (!name) return;
const cat = document.getElementById('add-item-category').value || null;
const amount = document.getElementById('add-item-amount').value.trim();
const unit = document.getElementById('add-item-unit').value.trim();
const note = document.getElementById('add-item-note').value.trim();
closeModal('modal-add-item');
const payload = {
item_name: name,
category: cat,
amount: amount,
unit: unit,
note: note,
source: ''
};
try {
const res = await fetch('/api/shopping-list', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (res.ok) {
const data = await res.json();
if (data.item) {
allItems.push(data.item);
saveLocalCache(allItems);
renderShoppingList();
renderCategoryFilterChips();
}
} else {
throw new Error('Server-Fehler');
}
} catch (err) {
// Offline Fallback
const tempItem = {
id: Date.now(),
item_name: name,
category: cat || 'Sonstiges',
amount: amount,
unit: unit,
note: note,
checked: false,
source: ''
};
allItems.push(tempItem);
pushOfflineQueue({ method: 'POST', url: '/api/shopping-list', body: payload });
saveLocalCache(allItems);
renderShoppingList();
renderCategoryFilterChips();
}
showToast('Eintrag hinzugefügt');
}
// ==========================================
// MODAL: EINTRAG BEARBEITEN (EDIT)
// ==========================================
function openEditModalById(itemId) {
const item = allItems.find(it => it.id === itemId);
if (!item) return;
document.getElementById('edit-item-id').value = item.id;
document.getElementById('edit-item-name').value = item.item_name || '';
document.getElementById('edit-item-amount').value = item.amount || '';
document.getElementById('edit-item-unit').value = item.unit || '';
document.getElementById('edit-item-note').value = item.note || '';
populateCategorySelect('edit-item-category', item.category || 'Sonstiges');
openModal('modal-edit-item');
setTimeout(() => document.getElementById('edit-item-name').focus(), 100);
}
async function onSaveEditItem(e) {
e.preventDefault();
const id = parseInt(document.getElementById('edit-item-id').value, 10);
const name = document.getElementById('edit-item-name').value.trim();
if (!name || !id) return;
const cat = document.getElementById('edit-item-category').value || 'Sonstiges';
const amount = document.getElementById('edit-item-amount').value.trim();
const unit = document.getElementById('edit-item-unit').value.trim();
const note = document.getElementById('edit-item-note').value.trim();
closeModal('modal-edit-item');
// Optimistic update
const item = allItems.find(it => it.id === id);
if (item) {
item.item_name = name;
item.category = cat;
item.amount = amount;
item.unit = unit;
item.note = note;
saveLocalCache(allItems);
renderShoppingList();
renderCategoryFilterChips();
}
const payload = {
item_name: name,
category: cat,
amount: amount,
unit: unit,
note: note
};
try {
await fetch(`/api/shopping-list/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
} catch (err) {
pushOfflineQueue({ method: 'PUT', url: `/api/shopping-list/${id}`, body: payload });
}
showToast('Eintrag aktualisiert');
}
function onDeleteItemFromModal() {
const id = parseInt(document.getElementById('edit-item-id').value, 10);
if (!id) return;
closeModal('modal-edit-item');
deleteItemDirectly(id);
}
// ==========================================
// 2-STUFEN LÖSCHEN MODALS
// ==========================================
function openDeleteStep1Modal() {
openModal('modal-delete-step1');
}
function openDeleteStep2Modal() {
closeModal('modal-delete-step1');
openModal('modal-delete-step2');
}
async function confirmClearAllItems() {
closeModal('modal-delete-step2');
allItems = [];
saveLocalCache([]);
renderShoppingList();
renderCategoryFilterChips();
showToast('Einkaufsliste komplett geleert');
try {
await fetch('/api/shopping-list/all', { method: 'DELETE' });
} catch (err) {
pushOfflineQueue({ method: 'DELETE', url: '/api/shopping-list/all' });
}
}
// ==========================================
// MODAL: WOCHENPLAN ZUTATEN IMPORTIEREN
// ==========================================
let mealPlanYear = null;
let mealPlanWeek = null;
async function openMealPlanImportModal(year, week) {
openModal('modal-import-mealplan');
const body = document.getElementById('mealplan-import-body');
const confirmBtn = document.getElementById('btn-confirm-import-mealplan');
if (confirmBtn) {
confirmBtn.disabled = true;
confirmBtn.textContent = 'Ausgewählte übernehmen';
}
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);">Zutaten des Wochenplans werden geladen...</div>
</div>
`;
try {
let url = '/api/mealplan/ingredients';
if (year && week) {
url += `?year=${encodeURIComponent(year)}&week=${encodeURIComponent(week)}`;
}
const res = await fetch(url);
if (!res.ok) throw new Error('Fehler beim Laden');
const data = await res.json();
mealPlanYear = data.year;
mealPlanWeek = data.week;
const groups = data.groups || [];
let navHeader = `
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; gap: 8px; flex-wrap: wrap;">
<div style="display: flex; align-items: center; gap: 6px;">
<button type="button" class="cb-btn cb-btn-secondary" style="padding: 3px 9px; font-size: 0.85rem;" onclick="changeMealPlanImportWeek(-1)" title="Vorherige Woche">◀</button>
<span style="font-weight: 700; font-size: 0.95rem;">KW ${data.week} / ${data.year}</span>
<button type="button" class="cb-btn cb-btn-secondary" style="padding: 3px 9px; font-size: 0.85rem;" onclick="changeMealPlanImportWeek(1)" title="Nächste Woche">▶</button>
</div>
<div style="display: flex; gap: 10px; align-items: center;">
<button type="button" onclick="toggleAllMealPlanChecks(true)" style="background: none; border: none; color: var(--shop-green); font-size: 0.85rem; font-weight: 700; cursor: pointer; text-decoration: underline;">
Alle
</button>
<span style="color: #ccc;">|</span>
<button type="button" onclick="toggleAllMealPlanChecks(false)" style="background: none; border: none; color: #888; font-size: 0.85rem; font-weight: 600; cursor: pointer; text-decoration: underline;">
Keine
</button>
</div>
</div>
`;
if (groups.length === 0) {
body.innerHTML = navHeader + `
<div style="text-align: center; padding: 25px; color: #666;">
<span style="font-size: 2.2rem; display: block; margin-bottom: 8px;">🍽️</span>
<p style="font-size: 1.05rem; margin: 0 0 6px 0; font-weight: 700; color: var(--shop-green);">Keine Rezepte in dieser Woche geplant</p>
<p style="font-size: 0.88rem; margin: 0; color: #777;">Blättere mit ◀ oder ▶ zu einer anderen Woche oder trage Gerichte im Kochbuch-Wochenmenüplan ein.</p>
</div>
`;
return;
}
let totalItems = 0;
let html = navHeader + `
<div style="max-height: 52vh; overflow-y: auto; display: flex; flex-direction: column; gap: 12px; padding-right: 4px;">
`;
groups.forEach((g, gIdx) => {
const items = g.items || g.ingredients || [];
totalItems += items.length;
html += `
<div style="background: rgba(30, 77, 59, 0.05); border: 1.5px solid rgba(30, 77, 59, 0.15); border-radius: 8px; padding: 10px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
<div style="font-family: var(--font-hand); font-size: 1.25rem; font-weight: 700; color: var(--shop-green);">
🍳 ${escapeHtml(g.recipe_title)}
</div>
${g.slot_label ? `<span style="font-size: 0.75rem; background: rgba(30, 77, 59, 0.12); color: var(--shop-green); padding: 2px 7px; border-radius: 6px; font-weight: 600;">${escapeHtml(g.slot_label)}</span>` : ''}
</div>
<div style="display: flex; flex-direction: column; gap: 6px;">
${items.map((ing, iIdx) => {
const ingName = ing.name || ing.item_name || ing.raw || '';
const amtStr = formatAmount(ing.amount, ing.unit);
return `
<label style="display: flex; align-items: center; gap: 8px; font-size: 0.92rem; cursor: pointer; padding: 3px 0;">
<input type="checkbox" class="mealplan-ing-checkbox" checked
data-name="${escapeAttr(ingName)}"
data-amount="${escapeAttr(ing.amount || '')}"
data-unit="${escapeAttr(ing.unit || '')}"
data-source="${escapeAttr(g.recipe_title || '')}"
onchange="updateMealPlanConfirmBtn()">
<span>${amtStr ? `<strong>${escapeHtml(amtStr)}</strong> ` : ''}${escapeHtml(ingName)}</span>
</label>
`;
}).join('')}
</div>
</div>
`;
});
html += `</div>`;
body.innerHTML = html;
if (confirmBtn) {
confirmBtn.disabled = totalItems === 0;
confirmBtn.textContent = `Ausgewählte übernehmen (${totalItems})`;
}
} catch (err) {
console.error("Fehler beim Laden des Wochenplans:", err);
body.innerHTML = `
<div style="text-align: center; padding: 20px; color: #b02a37;">
Fehler beim Laden des Wochenplans. Bitte stelle sicher, dass du online bist.
</div>
`;
}
}
function changeMealPlanImportWeek(delta) {
if (!mealPlanYear || !mealPlanWeek) return;
let w = mealPlanWeek + delta;
let y = mealPlanYear;
if (w < 1) {
y -= 1;
w = 52;
} else if (w > 52) {
y += 1;
w = 1;
}
openMealPlanImportModal(y, w);
}
function toggleAllMealPlanChecks(val) {
document.querySelectorAll('.mealplan-ing-checkbox').forEach(cb => cb.checked = val);
updateMealPlanConfirmBtn();
}
function updateMealPlanConfirmBtn() {
const checkedBoxes = Array.from(document.querySelectorAll('.mealplan-ing-checkbox:checked'));
const btn = document.getElementById('btn-confirm-import-mealplan');
if (btn) {
btn.disabled = checkedBoxes.length === 0;
btn.textContent = `Ausgewählte übernehmen (${checkedBoxes.length})`;
}
}
async function confirmImportMealPlanIngredients() {
const cbs = Array.from(document.querySelectorAll('.mealplan-ing-checkbox:checked'));
if (cbs.length === 0) return;
const confirmBtn = document.getElementById('btn-confirm-import-mealplan');
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-mealplan');
renderCategoryFilterChips();
renderShoppingList();
showToast(`✓ ${itemsToAdd.length} Zutaten übernommen`);
} else {
showToast('Fehler beim Übernehmen');
if (confirmBtn) {
confirmBtn.disabled = false;
confirmBtn.textContent = 'Ausgewählte übernehmen';
}
}
} catch (err) {
console.error("Fehler beim Importieren der Zutaten:", err);
showToast('Fehler beim Übernehmen');
if (confirmBtn) {
confirmBtn.disabled = false;
confirmBtn.textContent = 'Ausgewählte übernehmen';
}
}
}
// ==========================================
// MODAL: SUPERMARKT-BEREICHE VERWALTUNG
// ==========================================
function openCategoriesModal() {
renderManageCategoriesList();
openModal('modal-categories');
}
function renderManageCategoriesList() {
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('');
}
async function moveCategory(catId, dir) {
const idx = allCategories.findIndex(c => c.id === catId);
if (idx < 0) return;
const targetIdx = idx + dir;
if (targetIdx < 0 || targetIdx >= allCategories.length) return;
const [removed] = allCategories.splice(idx, 1);
allCategories.splice(targetIdx, 0, removed);
renderManageCategoriesList();
renderCategoryFilterChips();
renderShoppingList();
const orderedIds = allCategories.map(c => c.id);
try {
await fetch('/api/shopping-categories/reorder', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ category_ids: orderedIds })
});
} catch (e) {}
}
async function onAddCategory(e) {
e.preventDefault();
const iconInput = document.getElementById('new-cat-icon');
const nameInput = document.getElementById('new-cat-name');
const name = nameInput.value.trim();
const icon = iconInput.value.trim() || '🛒';
if (!name) return;
nameInput.value = '';
try {
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 })
});
if (res.ok) {
const d = await res.json();
allCategories = d.categories || [];
localStorage.setItem('wtc_shop_cats_cache', JSON.stringify(allCategories));
renderManageCategoriesList();
renderCategoryFilterChips();
showToast(`Bereich "${name}" angelegt`);
}
} catch (e) {}
}
async function deleteCategory(catId) {
if (!confirm('Diesen Supermarkt-Bereich wirklich entfernen?')) return;
try {
const res = await fetch(`/api/shopping-categories/${catId}`, { method: 'DELETE' });
if (res.ok) {
const d = await res.json();
allCategories = d.categories || [];
localStorage.setItem('wtc_shop_cats_cache', JSON.stringify(allCategories));
renderManageCategoriesList();
renderCategoryFilterChips();
renderShoppingList();
showToast('Bereich entfernt');
}
} catch (e) {}
}
async function resetDefaultCategories() {
if (!confirm('Möchtest du wirklich alle Supermarkt-Bereiche auf den Standard-Laufweg zurücksetzen?')) return;
try {
const res = await fetch('/api/shopping-categories/reset-defaults', { method: 'POST' });
if (res.ok) {
const d = await res.json();
allCategories = d.categories || [];
localStorage.setItem('wtc_shop_cats_cache', JSON.stringify(allCategories));
renderManageCategoriesList();
renderCategoryFilterChips();
renderShoppingList();
showToast('Standard-Bereiche wiederhergestellt');
}
} catch (e) {}
}
// ==========================================
// HELFER: MODAL OPEN / CLOSE & POPULATE
// ==========================================
function openModal(id) {
const m = document.getElementById(id);
if (m) {
m.classList.add('active');
document.body.classList.add('modal-open');
}
}
function closeModal(id) {
const m = document.getElementById(id);
if (m) {
m.classList.remove('active');
document.body.classList.remove('modal-open');
}
}
function onBackdropClick(e, id) {
if (e.target.id === id) {
closeModal(id);
}
}
function populateCategorySelect(selectId, selectedValue) {
const sel = document.getElementById(selectId);
if (!sel) return;
let html = selectId === 'add-item-category' ? '<option value="">🪄 Automatisch erkennen</option>' : '';
allCategories.forEach(cat => {
const isSel = (cat.name === selectedValue) ? 'selected' : '';
html += `<option value="${escapeAttr(cat.name)}" ${isSel}>${cat.icon || '🛒'} ${escapeHtml(cat.name)}</option>`;
});
if (!allCategories.some(c => c.name === 'Sonstiges')) {
const isSel = (selectedValue === 'Sonstiges') ? 'selected' : '';
html += `<option value="Sonstiges" ${isSel}>🛒 Sonstiges</option>`;
}
sel.innerHTML = html;
}
function showToast(msg) {
const toast = document.getElementById('shop-toast');
if (!toast) return;
toast.textContent = msg;
toast.classList.add('show');
setTimeout(() => toast.classList.remove('show'), 2200);
}
function escapeHtml(str) {
if (!str) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
function escapeAttr(str) {
if (!str) return '';
return String(str).replace(/"/g, '&quot;').replace(/'/g, '&#039;');
}
// ==========================================
// SERVICE WORKER & INITIALISIERUNG
// ==========================================
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch(err => console.log('SW fail:', err));
});
}
document.addEventListener('DOMContentLoaded', () => {
updateHideCheckedUI();
renderCategoryFilterChips();
renderShoppingList();
initWebSocket();
});
</script>
</body>
</html>