7eb06ed905
Native browser prompt() is unreliable on mobile browsers and PWAs, often requiring multiple clicks to close. Replace all prompt() calls with custom promptModal() and selectModal() functions that use the existing modal system with proper focus management and animations. Affected pages: shopping (create/rename list), tasks (add subtask), meals (choose shopping list). Fixes #12
722 lines
26 KiB
JavaScript
722 lines
26 KiB
JavaScript
/**
|
||
* Modul: Essensplan (Meals)
|
||
* Zweck: Wochenansicht mit Mahlzeit-CRUD, Zutaten-Verwaltung und Einkaufslisten-Integration
|
||
* Abhängigkeiten: /api.js, /router.js (window.oikos)
|
||
*/
|
||
|
||
import { api } from '/api.js';
|
||
import { openModal as openSharedModal, closeModal as closeSharedModal, selectModal } from '/components/modal.js';
|
||
import { stagger } from '/utils/ux.js';
|
||
import { t, formatDate } from '/i18n.js';
|
||
import { esc } from '/utils/html.js';
|
||
|
||
// --------------------------------------------------------
|
||
// Konstanten
|
||
// --------------------------------------------------------
|
||
|
||
const MEAL_TYPES = () => [
|
||
{ key: 'breakfast', label: t('meals.typeBreakfast'), icon: 'sunrise' },
|
||
{ key: 'lunch', label: t('meals.typeLunch'), icon: 'sun' },
|
||
{ key: 'dinner', label: t('meals.typeDinner'), icon: 'moon' },
|
||
{ key: 'snack', label: t('meals.typeSnack'), icon: 'cookie' },
|
||
];
|
||
|
||
const DAY_NAMES = () => [
|
||
t('meals.dayMo'), t('meals.dayDi'), t('meals.dayMi'), t('meals.dayDo'),
|
||
t('meals.dayFr'), t('meals.daySa'), t('meals.daySo'),
|
||
];
|
||
|
||
// --------------------------------------------------------
|
||
// State
|
||
// --------------------------------------------------------
|
||
|
||
let state = {
|
||
currentWeek: null, // YYYY-MM-DD (Montag)
|
||
meals: [],
|
||
lists: [], // Einkaufslisten für Transfer-Dropdown
|
||
modal: null,
|
||
};
|
||
|
||
// Container-Referenz für Hilfsfunktionen (wird in render() gesetzt)
|
||
let _container = null;
|
||
|
||
// --------------------------------------------------------
|
||
// Datumshelfer
|
||
// --------------------------------------------------------
|
||
|
||
function getMondayOf(dateStr) {
|
||
const d = new Date(dateStr + 'T00:00:00Z');
|
||
const day = d.getUTCDay();
|
||
const diff = (day === 0 ? -6 : 1 - day);
|
||
d.setUTCDate(d.getUTCDate() + diff);
|
||
return d.toISOString().slice(0, 10);
|
||
}
|
||
|
||
function addDays(dateStr, n) {
|
||
const d = new Date(dateStr + 'T00:00:00Z');
|
||
d.setUTCDate(d.getUTCDate() + n);
|
||
return d.toISOString().slice(0, 10);
|
||
}
|
||
|
||
function formatWeekLabel(monday) {
|
||
const sunday = addDays(monday, 6);
|
||
return `${formatDate(monday)} – ${formatDate(sunday)}`;
|
||
}
|
||
|
||
function isToday(dateStr) {
|
||
return dateStr === new Date().toISOString().slice(0, 10);
|
||
}
|
||
|
||
function formatDayDate(dateStr) {
|
||
return formatDate(dateStr);
|
||
}
|
||
|
||
// --------------------------------------------------------
|
||
// API-Wrapper
|
||
// --------------------------------------------------------
|
||
|
||
async function loadWeek(week) {
|
||
try {
|
||
const res = await api.get(`/meals?week=${week}`);
|
||
state.meals = res.data;
|
||
state.currentWeek = getMondayOf(week);
|
||
} catch (err) {
|
||
console.error('[Meals] loadWeek Fehler:', err);
|
||
state.meals = [];
|
||
state.currentWeek = getMondayOf(week);
|
||
window.oikos?.showToast(t('meals.loadError'), 'danger');
|
||
}
|
||
}
|
||
|
||
async function loadLists() {
|
||
try {
|
||
const res = await api.get('/shopping');
|
||
state.lists = res.data;
|
||
} catch {
|
||
state.lists = [];
|
||
}
|
||
}
|
||
|
||
// --------------------------------------------------------
|
||
// Render
|
||
// --------------------------------------------------------
|
||
|
||
export async function render(container, { user }) {
|
||
_container = container;
|
||
container.innerHTML = `
|
||
<div class="meals-page">
|
||
<h1 class="sr-only">${t('meals.title')}</h1>
|
||
<div class="week-nav">
|
||
<button class="btn btn--icon" id="week-prev" aria-label="${t('meals.prevWeek')}">
|
||
<i data-lucide="chevron-left" aria-hidden="true"></i>
|
||
</button>
|
||
<span class="week-nav__label" id="week-label"></span>
|
||
<button class="week-nav__today" id="week-today">${t('meals.today')}</button>
|
||
<button class="btn btn--icon" id="week-next" aria-label="${t('meals.nextWeek')}">
|
||
<i data-lucide="chevron-right" aria-hidden="true"></i>
|
||
</button>
|
||
</div>
|
||
<div class="week-grid" id="week-grid">
|
||
<div style="margin:auto;padding:2rem;text-align:center;color:var(--color-text-disabled)">${t('meals.loadingIndicator')}</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
if (window.lucide) lucide.createIcons();
|
||
|
||
const today = new Date().toISOString().slice(0, 10);
|
||
const monday = getMondayOf(today);
|
||
|
||
await Promise.all([loadWeek(monday), loadLists()]);
|
||
renderWeekGrid();
|
||
wireNav();
|
||
}
|
||
|
||
// --------------------------------------------------------
|
||
// Wochengitter
|
||
// --------------------------------------------------------
|
||
|
||
function renderWeekGrid() {
|
||
const grid = _container.querySelector('#week-grid');
|
||
if (!grid) return;
|
||
|
||
_container.querySelector('#week-label').textContent =
|
||
formatWeekLabel(state.currentWeek);
|
||
|
||
const days = Array.from({ length: 7 }, (_, i) => addDays(state.currentWeek, i));
|
||
const dayNames = DAY_NAMES();
|
||
|
||
grid.innerHTML = days.map((date, idx) => {
|
||
const mealsForDay = state.meals.filter((m) => m.date === date);
|
||
const todayClass = isToday(date) ? 'day-header--today' : '';
|
||
|
||
return `
|
||
<div class="day-column">
|
||
<div class="day-header ${todayClass}">
|
||
<span class="day-header__name">${dayNames[idx]}</span>
|
||
<span class="day-header__date">${formatDayDate(date)}</span>
|
||
</div>
|
||
<div class="day-slots">
|
||
${MEAL_TYPES().map((type) => renderSlot(date, type, mealsForDay)).join('')}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
|
||
if (window.lucide) lucide.createIcons();
|
||
stagger(grid.querySelectorAll('.meal-card'));
|
||
wireGrid(grid);
|
||
}
|
||
|
||
function renderSlot(date, type, mealsForDay) {
|
||
const meal = mealsForDay.find((m) => m.meal_type === type.key);
|
||
|
||
if (!meal) {
|
||
return `
|
||
<div class="meal-slot meal-slot--empty" data-date="${date}" data-type="${type.key}">
|
||
<div class="meal-slot__type-label">${type.label}</div>
|
||
<div class="empty-state empty-state--compact">
|
||
<div class="empty-state__description">${t('meals.noMealPlanned')}</div>
|
||
</div>
|
||
<button
|
||
class="meal-slot__add-btn"
|
||
data-action="add-meal"
|
||
data-date="${date}"
|
||
data-type="${type.key}"
|
||
aria-label="${t('meals.addMeal', { type: type.label })}"
|
||
>
|
||
<i data-lucide="plus" style="width:16px;height:16px;" aria-hidden="true"></i>
|
||
</button>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
const ingCount = meal.ingredients?.length ?? 0;
|
||
const ingDone = meal.ingredients?.filter((i) => i.on_shopping_list).length ?? 0;
|
||
const ingLabel = ingCount > 0 ? (ingCount !== 1 ? t('meals.ingredientCountPlural', { count: ingCount }) : t('meals.ingredientCount', { count: ingCount })) : '';
|
||
const ingDoneLabel = ingCount > 0 && ingDone === ingCount ? ' ✓' : '';
|
||
const canTransfer = ingCount > 0 && ingDone < ingCount;
|
||
|
||
return `
|
||
<div class="meal-slot meal-slot--has-meal" data-meal-id="${meal.id}" data-date="${meal.date}" data-type="${type.key}">
|
||
<div class="meal-slot__type-label">${type.label}</div>
|
||
<div class="meal-card"
|
||
data-action="edit-meal"
|
||
data-meal-id="${meal.id}"
|
||
role="button" tabindex="0">
|
||
<div class="meal-card__title">${esc(meal.title)}</div>
|
||
${ingLabel ? `<div class="meal-card__meta">
|
||
<span class="meal-card__ingredients-count">${ingLabel}${esc(ingDoneLabel)}</span>
|
||
</div>` : ''}
|
||
<div class="meal-card__actions">
|
||
${canTransfer ? `<button class="meal-card__action-btn meal-card__action-btn--shopping"
|
||
data-action="transfer-meal"
|
||
data-meal-id="${meal.id}"
|
||
aria-label="${t('meals.transferToShoppingList')}"
|
||
><i data-lucide="shopping-cart" style="width:14px;height:14px;" aria-hidden="true"></i></button>` : ''}
|
||
<button class="meal-card__action-btn"
|
||
data-action="delete-meal"
|
||
data-meal-id="${meal.id}"
|
||
aria-label="${t('meals.deleteMeal')}"
|
||
><i data-lucide="trash-2" style="width:14px;height:14px;" aria-hidden="true"></i></button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// --------------------------------------------------------
|
||
// Event-Delegation
|
||
// --------------------------------------------------------
|
||
|
||
function wireNav() {
|
||
_container.querySelector('#week-prev')?.addEventListener('click', async () => {
|
||
await loadWeek(addDays(state.currentWeek, -7));
|
||
renderWeekGrid();
|
||
});
|
||
|
||
_container.querySelector('#week-next')?.addEventListener('click', async () => {
|
||
await loadWeek(addDays(state.currentWeek, 7));
|
||
renderWeekGrid();
|
||
});
|
||
|
||
_container.querySelector('#week-today')?.addEventListener('click', async () => {
|
||
const monday = getMondayOf(new Date().toISOString().slice(0, 10));
|
||
if (monday === state.currentWeek) return;
|
||
await loadWeek(monday);
|
||
renderWeekGrid();
|
||
});
|
||
}
|
||
|
||
function wireGrid(grid) {
|
||
grid.addEventListener('click', async (e) => {
|
||
const btn = e.target.closest('[data-action]');
|
||
if (!btn) return;
|
||
|
||
const action = btn.dataset.action;
|
||
|
||
if (action === 'add-meal') {
|
||
openMealModal({ mode: 'create', date: btn.dataset.date, mealType: btn.dataset.type });
|
||
return;
|
||
}
|
||
|
||
if (action === 'edit-meal') {
|
||
const mealId = parseInt(btn.dataset.mealId, 10);
|
||
const meal = state.meals.find((m) => m.id === mealId);
|
||
if (meal) openMealModal({ mode: 'edit', meal, date: meal.date, mealType: meal.meal_type });
|
||
return;
|
||
}
|
||
|
||
if (action === 'delete-meal') {
|
||
await deleteMeal(parseInt(btn.dataset.mealId, 10));
|
||
return;
|
||
}
|
||
|
||
if (action === 'transfer-meal') {
|
||
await transferMeal(parseInt(btn.dataset.mealId, 10));
|
||
}
|
||
});
|
||
|
||
grid.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter' || e.key === ' ') {
|
||
const card = e.target.closest('[data-action="edit-meal"]');
|
||
if (card) { e.preventDefault(); card.click(); }
|
||
}
|
||
});
|
||
|
||
wireDragDrop(grid);
|
||
}
|
||
|
||
// --------------------------------------------------------
|
||
// Drag & Drop
|
||
// --------------------------------------------------------
|
||
|
||
let _suppressNextClick = false;
|
||
|
||
function wireDragDrop(grid) {
|
||
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||
let dragging = null; // { mealId, sourceDate, sourceType, ghost, startX, startY }
|
||
|
||
grid.addEventListener('pointerdown', (e) => {
|
||
const card = e.target.closest('.meal-card');
|
||
if (!card) return;
|
||
if (e.target.closest('[data-action="delete-meal"], [data-action="transfer-meal"]')) return;
|
||
|
||
const slot = card.closest('.meal-slot');
|
||
if (!slot) return;
|
||
|
||
const mealId = parseInt(slot.dataset.mealId, 10);
|
||
const sourceDate = slot.dataset.date;
|
||
const sourceType = slot.dataset.type;
|
||
|
||
e.preventDefault();
|
||
card.setPointerCapture(e.pointerId);
|
||
|
||
let ghost = null;
|
||
if (!reducedMotion) {
|
||
ghost = card.cloneNode(true);
|
||
ghost.classList.add('meal-card--ghost');
|
||
ghost.style.width = card.offsetWidth + 'px';
|
||
ghost.style.height = card.offsetHeight + 'px';
|
||
ghost.style.left = (e.clientX - card.offsetWidth / 2) + 'px';
|
||
ghost.style.top = (e.clientY - card.offsetHeight / 2) + 'px';
|
||
document.body.appendChild(ghost);
|
||
}
|
||
|
||
slot.classList.add('meal-slot--dragging');
|
||
dragging = { mealId, sourceDate, sourceType, ghost, card, slot };
|
||
|
||
let lastTarget = null;
|
||
|
||
function onMove(ev) {
|
||
if (!dragging) return;
|
||
if (ghost) {
|
||
ghost.style.left = (ev.clientX - ghost.offsetWidth / 2) + 'px';
|
||
ghost.style.top = (ev.clientY - ghost.offsetHeight / 2) + 'px';
|
||
}
|
||
if (ghost) ghost.style.display = 'none';
|
||
const el = document.elementFromPoint(ev.clientX, ev.clientY);
|
||
if (ghost) ghost.style.display = '';
|
||
|
||
const targetSlot = el?.closest('.meal-slot');
|
||
if (targetSlot !== lastTarget) {
|
||
lastTarget?.classList.remove('meal-slot--drop-target');
|
||
if (targetSlot && targetSlot !== dragging.slot) {
|
||
targetSlot.classList.add('meal-slot--drop-target');
|
||
}
|
||
lastTarget = targetSlot;
|
||
}
|
||
}
|
||
|
||
async function onUp(ev) {
|
||
if (!dragging) return;
|
||
const { mealId, sourceDate, sourceType, slot: sourceSlot } = dragging;
|
||
cleanup(); // setzt dragging = null - Werte daher vorher destrukturieren
|
||
|
||
if (ghost) ghost.style.display = 'none';
|
||
const el = document.elementFromPoint(ev.clientX, ev.clientY);
|
||
if (ghost) ghost.style.display = '';
|
||
|
||
const targetSlot = el?.closest('.meal-slot');
|
||
if (targetSlot && targetSlot !== sourceSlot) {
|
||
const targetDate = targetSlot.dataset.date;
|
||
const targetType = targetSlot.dataset.type;
|
||
const targetMealId = targetSlot.dataset.mealId ? parseInt(targetSlot.dataset.mealId, 10) : null;
|
||
_suppressNextClick = true;
|
||
setTimeout(() => { _suppressNextClick = false; }, 300);
|
||
await moveMeal(mealId, sourceDate, sourceType, targetDate, targetType, targetMealId);
|
||
}
|
||
}
|
||
|
||
function onCancel() { cleanup(); }
|
||
|
||
function cleanup() {
|
||
ghost?.remove();
|
||
dragging?.slot?.classList.remove('meal-slot--dragging');
|
||
lastTarget?.classList.remove('meal-slot--drop-target');
|
||
dragging = null;
|
||
card.removeEventListener('pointermove', onMove);
|
||
card.removeEventListener('pointerup', onUp);
|
||
card.removeEventListener('pointercancel', onCancel);
|
||
}
|
||
|
||
card.addEventListener('pointermove', onMove);
|
||
card.addEventListener('pointerup', onUp);
|
||
card.addEventListener('pointercancel', onCancel);
|
||
});
|
||
|
||
// Suppress click after a completed drag
|
||
grid.addEventListener('click', (e) => {
|
||
if (_suppressNextClick) {
|
||
e.stopImmediatePropagation();
|
||
_suppressNextClick = false;
|
||
}
|
||
}, true);
|
||
}
|
||
|
||
async function moveMeal(mealId, sourceDate, sourceType, targetDate, targetType, targetMealId) {
|
||
try {
|
||
if (targetMealId) {
|
||
// Swap: move both meals to each other's slots
|
||
await Promise.all([
|
||
api.put(`/meals/${mealId}`, { date: targetDate, meal_type: targetType }),
|
||
api.put(`/meals/${targetMealId}`, { date: sourceDate, meal_type: sourceType }),
|
||
]);
|
||
const m1 = state.meals.find((m) => m.id === mealId);
|
||
const m2 = state.meals.find((m) => m.id === targetMealId);
|
||
if (m1) { m1.date = targetDate; m1.meal_type = targetType; }
|
||
if (m2) { m2.date = sourceDate; m2.meal_type = sourceType; }
|
||
} else {
|
||
// Move to empty slot
|
||
await api.put(`/meals/${mealId}`, { date: targetDate, meal_type: targetType });
|
||
const m = state.meals.find((m) => m.id === mealId);
|
||
if (m) { m.date = targetDate; m.meal_type = targetType; }
|
||
}
|
||
renderWeekGrid();
|
||
} catch {
|
||
// Re-render to restore visual state
|
||
renderWeekGrid();
|
||
}
|
||
}
|
||
|
||
// --------------------------------------------------------
|
||
// Modal
|
||
// --------------------------------------------------------
|
||
|
||
function openMealModal(opts) {
|
||
state.modal = opts;
|
||
const { mode, date, mealType, meal } = opts;
|
||
const isEdit = mode === 'edit';
|
||
|
||
const content = buildModalContent(opts);
|
||
|
||
openSharedModal({
|
||
title: isEdit ? t('meals.editMeal') : t('meals.addMealTitle'),
|
||
content,
|
||
size: 'md',
|
||
onSave(panel) {
|
||
// Autocomplete
|
||
const titleInput = panel.querySelector('#modal-title');
|
||
const acDropdown = panel.querySelector('#modal-autocomplete');
|
||
let acIndex = -1;
|
||
let acTimer;
|
||
|
||
titleInput.addEventListener('input', () => {
|
||
clearTimeout(acTimer);
|
||
acTimer = setTimeout(async () => {
|
||
const q = titleInput.value.trim();
|
||
if (!q) { acDropdown.hidden = true; return; }
|
||
try {
|
||
const res = await api.get(`/meals/suggestions?q=${encodeURIComponent(q)}`);
|
||
if (!res.data.length) { acDropdown.hidden = true; return; }
|
||
acIndex = -1;
|
||
acDropdown.innerHTML = res.data.map((s) => `
|
||
<div class="meal-modal__autocomplete-item" data-title="${esc(s.title)}">${esc(s.title)}</div>
|
||
`).join('');
|
||
acDropdown.hidden = false;
|
||
} catch { acDropdown.hidden = true; }
|
||
}, 200);
|
||
});
|
||
|
||
titleInput.addEventListener('keydown', (e) => {
|
||
const items = [...acDropdown.querySelectorAll('.meal-modal__autocomplete-item')];
|
||
if (!items.length) return;
|
||
if (e.key === 'ArrowDown') { e.preventDefault(); acIndex = Math.min(acIndex + 1, items.length - 1); items.forEach((el, i) => el.classList.toggle('meal-modal__autocomplete-item--active', i === acIndex)); }
|
||
if (e.key === 'ArrowUp') { e.preventDefault(); acIndex = Math.max(acIndex - 1, 0); items.forEach((el, i) => el.classList.toggle('meal-modal__autocomplete-item--active', i === acIndex)); }
|
||
if (e.key === 'Enter' && acIndex >= 0) { e.preventDefault(); titleInput.value = items[acIndex].dataset.title; acDropdown.hidden = true; acIndex = -1; }
|
||
if (e.key === 'Escape') acDropdown.hidden = true;
|
||
});
|
||
|
||
acDropdown.addEventListener('mousedown', (e) => {
|
||
const item = e.target.closest('.meal-modal__autocomplete-item');
|
||
if (item) { titleInput.value = item.dataset.title; acDropdown.hidden = true; }
|
||
});
|
||
|
||
// Zutaten
|
||
const ingList = panel.querySelector('#ingredient-list');
|
||
const addIngBtn = panel.querySelector('#add-ingredient-btn');
|
||
|
||
addIngBtn.addEventListener('click', () => {
|
||
const tmp = document.createElement('div');
|
||
tmp.innerHTML = ingredientRowHTML('', '', null);
|
||
const row = tmp.firstElementChild;
|
||
ingList.appendChild(row);
|
||
if (window.lucide) lucide.createIcons();
|
||
row.querySelector('input').focus();
|
||
});
|
||
|
||
ingList.addEventListener('click', (e) => {
|
||
const btn = e.target.closest('[data-action="remove-ingredient"]');
|
||
if (btn) btn.closest('.ingredient-row').remove();
|
||
});
|
||
|
||
// Einkaufslisten-Transfer
|
||
panel.querySelector('#transfer-btn')?.addEventListener('click', async () => {
|
||
const selectEl = panel.querySelector('#transfer-list-select');
|
||
const listId = parseInt(selectEl?.value, 10);
|
||
if (!listId || !state.modal?.meal) return;
|
||
const btn = panel.querySelector('#transfer-btn');
|
||
btn.disabled = true;
|
||
try {
|
||
const res = await api.post(`/meals/${state.modal.meal.id}/to-shopping-list`, { listId });
|
||
if (res.data.transferred > 0) {
|
||
window.oikos?.showToast(res.data.transferred !== 1 ? t('meals.transferSuccessPlural', { count: res.data.transferred }) : t('meals.transferSuccess', { count: res.data.transferred }), 'success');
|
||
await loadWeek(state.currentWeek);
|
||
closeModal();
|
||
renderWeekGrid();
|
||
} else {
|
||
window.oikos?.showToast(t('meals.transferAlreadyDone'), 'info');
|
||
btn.disabled = false;
|
||
}
|
||
} catch (err) {
|
||
window.oikos?.showToast(err.data?.error ?? t('common.unknownError'), 'error');
|
||
btn.disabled = false;
|
||
}
|
||
});
|
||
|
||
panel.querySelector('#modal-cancel').addEventListener('click', closeModal);
|
||
panel.querySelector('#modal-save').addEventListener('click', () => saveModal(panel));
|
||
},
|
||
});
|
||
}
|
||
|
||
function buildModalContent({ mode, date, mealType, meal }) {
|
||
const isEdit = mode === 'edit';
|
||
const typeOpts = MEAL_TYPES().map((mt) =>
|
||
`<option value="${mt.key}" ${mt.key === mealType ? 'selected' : ''}>${mt.label}</option>`
|
||
).join('');
|
||
|
||
const listOpts = state.lists.length
|
||
? state.lists.map((l) => `<option value="${l.id}">${esc(l.name)}</option>`).join('')
|
||
: `<option value="" disabled>${t('meals.noShoppingLists')}</option>`;
|
||
|
||
const ingRows = isEdit && meal.ingredients?.length
|
||
? meal.ingredients.map((ing) => ingredientRowHTML(ing.name, ing.quantity ?? '', ing.id)).join('')
|
||
: '';
|
||
|
||
const hasIngOpen = isEdit && meal.ingredients?.some((i) => !i.on_shopping_list);
|
||
|
||
return `
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
|
||
<div class="form-group" style="margin-bottom:0">
|
||
<label class="form-label" for="modal-date">${t('meals.dateLabel')}</label>
|
||
<input type="date" class="form-input" id="modal-date" value="${date}">
|
||
</div>
|
||
<div class="form-group" style="margin-bottom:0">
|
||
<label class="form-label" for="modal-type">${t('meals.mealTypeLabel')}</label>
|
||
<select class="form-input" id="modal-type">${typeOpts}</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-group" style="position:relative;">
|
||
<label class="form-label" for="modal-title">${t('meals.titleLabel')}</label>
|
||
<input type="text" class="form-input" id="modal-title"
|
||
placeholder="${t('meals.titlePlaceholder')}"
|
||
value="${esc(isEdit ? meal.title : '')}"
|
||
autocomplete="off">
|
||
<div id="modal-autocomplete" class="meal-modal__autocomplete" hidden></div>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label class="form-label" for="modal-notes">${t('meals.notesLabel')}</label>
|
||
<textarea class="form-input" id="modal-notes" rows="2"
|
||
placeholder="${t('meals.notesPlaceholder')}">${esc(isEdit && meal.notes ? meal.notes : '')}</textarea>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label class="form-label">${t('meals.ingredientsLabel')}</label>
|
||
<div class="ingredient-list" id="ingredient-list">${ingRows}</div>
|
||
<button class="add-ingredient-btn" id="add-ingredient-btn" type="button">
|
||
<i data-lucide="plus" style="width:14px;height:14px;" aria-hidden="true"></i>
|
||
${t('meals.addIngredient')}
|
||
</button>
|
||
</div>
|
||
|
||
${isEdit && hasIngOpen ? `
|
||
<div class="shopping-transfer">
|
||
<div class="shopping-transfer__label">
|
||
<i data-lucide="shopping-cart" style="width:14px;height:14px;" aria-hidden="true"></i>
|
||
${t('meals.transferLabel')}
|
||
</div>
|
||
<select class="shopping-transfer__select" id="transfer-list-select">${listOpts}</select>
|
||
<button class="btn btn--secondary shopping-transfer__btn" id="transfer-btn" type="button">
|
||
${t('meals.transferNow')}
|
||
</button>
|
||
</div>` : ''}
|
||
|
||
<div class="modal-panel__footer" style="border:none;padding:0;margin-top:var(--space-4)">
|
||
<button class="btn btn--secondary" id="modal-cancel">${t('common.cancel')}</button>
|
||
<button class="btn btn--primary" id="modal-save">${isEdit ? t('common.save') : t('common.add')}</button>
|
||
</div>`;
|
||
}
|
||
|
||
function ingredientRowHTML(name, qty, id) {
|
||
return `
|
||
<div class="ingredient-row" data-ing-id="${id ?? ''}">
|
||
<input type="text" class="form-input ingredient-row__name" placeholder="${t('meals.ingredientNamePlaceholder')}" value="${esc(name)}">
|
||
<input type="text" class="form-input ingredient-row__qty" placeholder="${t('meals.ingredientQtyPlaceholder')}" value="${esc(qty)}">
|
||
<button class="ingredient-row__remove" data-action="remove-ingredient" type="button" aria-label="${t('meals.removeIngredient')}">
|
||
<i data-lucide="x" style="width:14px;height:14px;" aria-hidden="true"></i>
|
||
</button>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function closeModal() {
|
||
closeSharedModal();
|
||
state.modal = null;
|
||
}
|
||
|
||
async function saveModal(overlay) {
|
||
const saveBtn = overlay.querySelector('#modal-save');
|
||
const date = overlay.querySelector('#modal-date').value;
|
||
const meal_type = overlay.querySelector('#modal-type').value;
|
||
const title = overlay.querySelector('#modal-title').value.trim();
|
||
const notes = overlay.querySelector('#modal-notes').value.trim() || null;
|
||
|
||
if (!title) {
|
||
window.oikos?.showToast(t('meals.titleRequired'), 'error');
|
||
return;
|
||
}
|
||
|
||
const ingredients = [];
|
||
overlay.querySelectorAll('.ingredient-row').forEach((row) => {
|
||
const name = row.querySelector('.ingredient-row__name').value.trim();
|
||
const qty = row.querySelector('.ingredient-row__qty').value.trim() || null;
|
||
if (name) ingredients.push({ name, quantity: qty, id: row.dataset.ingId || null });
|
||
});
|
||
|
||
saveBtn.disabled = true;
|
||
saveBtn.textContent = '…';
|
||
|
||
try {
|
||
const { mode, meal } = state.modal;
|
||
|
||
if (mode === 'create') {
|
||
const res = await api.post('/meals', { date, meal_type, title, notes, ingredients });
|
||
state.meals.push(res.data);
|
||
} else {
|
||
// Update meal meta
|
||
await api.put(`/meals/${meal.id}`, { date, meal_type, title, notes });
|
||
|
||
// Sync ingredients
|
||
const existingIds = new Set((meal.ingredients ?? []).map((i) => i.id));
|
||
const keptIds = new Set(
|
||
ingredients.filter((i) => i.id).map((i) => parseInt(i.id, 10))
|
||
);
|
||
|
||
for (const id of existingIds) {
|
||
if (!keptIds.has(id)) await api.delete(`/meals/ingredients/${id}`);
|
||
}
|
||
for (const ing of ingredients) {
|
||
if (!ing.id) await api.post(`/meals/${meal.id}/ingredients`, { name: ing.name, quantity: ing.quantity });
|
||
}
|
||
|
||
// Reload updated meal
|
||
await loadWeek(state.currentWeek);
|
||
}
|
||
|
||
closeModal();
|
||
renderWeekGrid();
|
||
window.oikos?.showToast(mode === 'create' ? t('meals.addMealTitle') : t('meals.editMeal'), 'success');
|
||
} catch (err) {
|
||
window.oikos?.showToast(err.data?.error ?? t('common.errorGeneric'), 'error');
|
||
saveBtn.disabled = false;
|
||
saveBtn.textContent = state.modal?.mode === 'edit' ? t('common.save') : t('common.add');
|
||
}
|
||
}
|
||
|
||
// --------------------------------------------------------
|
||
// Mahlzeit löschen
|
||
// --------------------------------------------------------
|
||
|
||
async function deleteMeal(mealId) {
|
||
if (!confirm(t('meals.deleteMeal') + '?')) return;
|
||
try {
|
||
await api.delete(`/meals/${mealId}`);
|
||
state.meals = state.meals.filter((m) => m.id !== mealId);
|
||
renderWeekGrid();
|
||
window.oikos?.showToast(t('meals.deleteMeal'), 'success');
|
||
} catch (err) {
|
||
window.oikos?.showToast(err.data?.error ?? t('common.errorGeneric'), 'error');
|
||
}
|
||
}
|
||
|
||
// --------------------------------------------------------
|
||
// Zutaten → Einkaufsliste (Quick-Transfer vom Slot aus)
|
||
// --------------------------------------------------------
|
||
|
||
async function transferMeal(mealId) {
|
||
if (!state.lists.length) {
|
||
window.oikos?.showToast(t('meals.noShoppingLists'), 'error');
|
||
return;
|
||
}
|
||
|
||
let listId = state.lists[0].id;
|
||
|
||
if (state.lists.length > 1) {
|
||
const options = state.lists.map((l) => ({ value: l.id, label: l.name }));
|
||
const choice = await selectModal(t('meals.transferToShoppingList'), options);
|
||
if (choice === null) return;
|
||
listId = Number(choice);
|
||
}
|
||
|
||
try {
|
||
const res = await api.post(`/meals/${mealId}/to-shopping-list`, { listId });
|
||
if (res.data.transferred > 0) {
|
||
window.oikos?.showToast(res.data.transferred !== 1 ? t('meals.transferSuccessPlural', { count: res.data.transferred }) : t('meals.transferSuccess', { count: res.data.transferred }), 'success');
|
||
await loadWeek(state.currentWeek);
|
||
renderWeekGrid();
|
||
} else {
|
||
window.oikos?.showToast(t('meals.transferAlreadyDone'), 'info');
|
||
}
|
||
} catch (err) {
|
||
window.oikos?.showToast(err.data?.error ?? t('common.errorGeneric'), 'error');
|
||
}
|
||
}
|
||
|
||
// --------------------------------------------------------
|
||
// Hilfsfunktion
|
||
// --------------------------------------------------------
|
||
|