2553 lines
99 KiB
HTML
2553 lines
99 KiB
HTML
<!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);
|
||
}
|
||
|
||
/* 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;
|
||
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="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>
|
||
<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(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>
|
||
<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 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>
|
||
|
||
<!-- 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">×</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">×</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">×</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">×</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">×</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: 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">×</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) -->
|
||
<!-- ========================================== -->
|
||
<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">×</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 -->
|
||
<!-- ========================================== -->
|
||
<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">×</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>
|
||
|
||
<!-- 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;">
|
||
<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. 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>
|
||
</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 zurücksetzen
|
||
</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 = [];
|
||
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') || '[]');
|
||
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 deduplicateItems(items) {
|
||
if (!Array.isArray(items)) return [];
|
||
const seenIds = new Set();
|
||
const unique = [];
|
||
for (const item of items) {
|
||
if (!item) continue;
|
||
const key = item.id ? `id_${item.id}` : `name_${(item.item_name || '').toLowerCase().trim()}_${item.checked ? '1' : '0'}`;
|
||
if (!seenIds.has(key)) {
|
||
seenIds.add(key);
|
||
unique.push(item);
|
||
}
|
||
}
|
||
return unique;
|
||
}
|
||
|
||
function saveLocalCache(items) {
|
||
allItems = deduplicateItems(items);
|
||
try {
|
||
localStorage.setItem('wtc_shop_items_cache', JSON.stringify(allItems));
|
||
} 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 (dedupliziert gegen parallele WebSocket- & Fetch-Ereignisse)
|
||
let filtered = deduplicateItems(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, 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: '🛒', 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 class="shop-cat-step-num">${stepNum}</span>
|
||
<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);
|
||
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}">
|
||
<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>
|
||
|
||
<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>
|
||
|
||
<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.items && Array.isArray(data.items)) {
|
||
allItems = data.items;
|
||
} else if (data.item) {
|
||
const existingIdx = allItems.findIndex(it => it.id === data.item.id);
|
||
if (existingIdx >= 0) {
|
||
allItems[existingIdx] = data.item;
|
||
} else {
|
||
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: ''
|
||
};
|
||
const existingIdx = allItems.findIndex(it => (it.item_name || '').toLowerCase().trim() === name.toLowerCase().trim() && !it.checked);
|
||
if (existingIdx >= 0) {
|
||
allItems[existingIdx] = tempItem;
|
||
} else {
|
||
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: 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
|
||
// ==========================================
|
||
function openCategoriesModal() {
|
||
renderManageCategoriesList();
|
||
openModal('modal-categories');
|
||
}
|
||
|
||
function renderManageCategoriesList() {
|
||
const ul = document.getElementById('cat-manage-list');
|
||
if (!ul) return;
|
||
|
||
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) {
|
||
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);
|
||
allCategories.forEach((c, i) => c.sort_order = i + 1);
|
||
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 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 = '';
|
||
|
||
try {
|
||
const res = await fetch('/api/shopping-categories', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
name: name,
|
||
icon: icon,
|
||
shelf_type: shelfType,
|
||
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();
|
||
renderShoppingList();
|
||
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 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?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(`✓ Laufweg "${label}" aktiviert`);
|
||
}
|
||
} 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 }
|
||
});
|
||
}
|
||
}
|
||
|
||
// ==========================================
|
||
// 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, idx) => {
|
||
const isSel = (cat.name === selectedValue) ? 'selected' : '';
|
||
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')) {
|
||
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, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
}
|
||
|
||
function escapeAttr(str) {
|
||
if (!str) return '';
|
||
return String(str).replace(/"/g, '"').replace(/'/g, ''');
|
||
}
|
||
|
||
// ==========================================
|
||
// 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();
|
||
|
||
// 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>
|
||
</html>
|