/**
* 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, dateInputPlaceholder, formatDateInput, parseDateInput, isDateInputValid } from '/i18n.js';
import { esc } from '/utils/html.js';
import { DEFAULT_CATEGORY_NAME, categoryLabel } from '/utils/shopping-categories.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'),
];
const EXCLUDED_MEAL_CATEGORY_NAMES = new Set(['Haushalt', 'Drogerie']);
// --------------------------------------------------------
// State
// --------------------------------------------------------
let state = {
currentWeek: null, // YYYY-MM-DD (Montag)
meals: [],
recipes: [],
lists: [], // Einkaufslisten für Transfer-Dropdown
categories: [], // Einkaufskategorien für Zutaten
modal: null,
visibleMealTypes: ['breakfast', 'lunch', 'dinner', 'snack'],
};
// 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);
}
function mealCategories() {
return state.categories.filter((c) => !EXCLUDED_MEAL_CATEGORY_NAMES.has(c.name));
}
// --------------------------------------------------------
// 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 = [];
}
}
async function loadCategories() {
try {
const res = await api.get('/shopping/categories');
state.categories = res.data;
} catch {
state.categories = [];
}
}
async function loadRecipes() {
try {
const res = await api.get('/recipes');
state.recipes = res.data;
} catch {
state.recipes = [];
}
}
async function loadPreferences() {
try {
const res = await api.get('/preferences');
state.visibleMealTypes = res.data.visible_meal_types ?? state.visibleMealTypes;
} catch {
// Default beibehalten
}
}
// --------------------------------------------------------
// Render
// --------------------------------------------------------
export async function render(container, { user }) {
_container = container;
container.innerHTML = `
${t('meals.title')}
${t('meals.loadingIndicator')}
`;
if (window.lucide) lucide.createIcons();
const today = new Date().toISOString().slice(0, 10);
const monday = getMondayOf(today);
await Promise.all([loadWeek(monday), loadLists(), loadPreferences(), loadCategories(), loadRecipes()]);
renderWeekGrid();
wireNav();
const selectedRecipeId = Number(new URLSearchParams(window.location.search).get('recipe'));
if (selectedRecipeId) {
const selectedRecipe = state.recipes.find((r) => r.id === selectedRecipeId);
if (selectedRecipe) {
const firstType = state.visibleMealTypes[0] ?? 'lunch';
openMealModal({ mode: 'create', date: today, mealType: firstType, presetRecipeId: selectedRecipe.id });
}
}
container.querySelector('#fab-new-meal').addEventListener('click', () => {
const firstType = state.visibleMealTypes[0] ?? 'lunch';
openMealModal({ mode: 'create', date: today, mealType: firstType });
});
}
// --------------------------------------------------------
// 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 `
${MEAL_TYPES().filter((type) => state.visibleMealTypes.includes(type.key)).map((type) => renderSlot(date, type, mealsForDay)).join('')}
`;
}).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 `
${type.label}
${t('meals.noMealPlanned')}
`;
}
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 `
${type.label}
${esc(meal.title)}
${ingLabel ? `
${ingLabel}${esc(ingDoneLabel)}
` : ''}
${meal.recipe_url ? `
` : ''}
${canTransfer ? `
` : ''}
`;
}
// --------------------------------------------------------
// 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 === 'open-recipe') {
// Link öffnet sich nativ - nur Bubbling stoppen damit kein Edit-Modal aufgeht
e.stopPropagation();
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"], [data-action="open-recipe"]')) 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, presetRecipeId = null } = 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) => `
${esc(s.title)}
`).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');
const recipeSelect = panel.querySelector('#modal-recipe-id');
const recipeScaleInput = panel.querySelector('#modal-recipe-scale');
const saveAsRecipeBtn = panel.querySelector('#modal-save-as-recipe');
let currentAppliedRecipe = null;
const scaleQuantityText = (quantity, factor) => {
if (!quantity || factor === 1) return quantity;
const formatNumber = (num, useComma = false) => {
const rounded = Math.round(num * 100) / 100;
if (Number.isInteger(rounded)) return String(rounded);
const text = String(rounded);
return useComma ? text.replace('.', ',') : text;
};
const mixed = quantity.match(/^(\d+)\s+(\d+)\/(\d+)(.*)$/);
if (mixed) {
const whole = Number(mixed[1]);
const num = Number(mixed[2]);
const den = Number(mixed[3]);
if (den > 0) {
const value = (whole + (num / den)) * factor;
return `${formatNumber(value)}${mixed[4]}`;
}
}
const frac = quantity.match(/^(\d+)\/(\d+)(.*)$/);
if (frac) {
const num = Number(frac[1]);
const den = Number(frac[2]);
if (den > 0) {
const value = (num / den) * factor;
return `${formatNumber(value)}${frac[3]}`;
}
}
const dec = quantity.match(/^(\d+(?:[.,]\d+)?)(.*)$/);
if (dec) {
const useComma = dec[1].includes(',');
const base = Number(dec[1].replace(',', '.'));
if (Number.isFinite(base)) {
return `${formatNumber(base * factor, useComma)}${dec[2]}`;
}
}
return quantity;
};
const applyRecipe = (recipeId) => {
const id = Number(recipeId);
const factor = Math.max(Number(recipeScaleInput?.value || 1), 0.1);
if (!id) {
currentAppliedRecipe = null;
return;
}
const recipe = state.recipes.find((r) => r.id === id);
if (!recipe) return;
currentAppliedRecipe = recipe;
panel.querySelector('#modal-title').value = recipe.title || '';
panel.querySelector('#modal-notes').value = recipe.notes || '';
panel.querySelector('#modal-recipe-url').value = recipe.recipe_url || '';
ingList.innerHTML = (recipe.ingredients || [])
.map((ing) => {
const scaledQty = scaleQuantityText(ing.quantity ?? '', factor);
return ingredientRowHTML(ing.name, scaledQty, null, ing.category ?? DEFAULT_CATEGORY_NAME);
})
.join('');
if (window.lucide) lucide.createIcons();
};
recipeSelect?.addEventListener('change', () => {
if (recipeScaleInput) recipeScaleInput.value = '1';
applyRecipe(recipeSelect.value);
});
recipeScaleInput?.addEventListener('input', () => {
const currentRecipeId = Number(recipeSelect?.value || 0);
if (!currentRecipeId || !currentAppliedRecipe) return;
const factor = Number(recipeScaleInput.value || 1);
if (!Number.isFinite(factor) || factor <= 0) return;
ingList.innerHTML = (currentAppliedRecipe.ingredients || [])
.map((ing) => ingredientRowHTML(
ing.name,
scaleQuantityText(ing.quantity ?? '', Math.max(factor, 0.1)),
null,
ing.category ?? DEFAULT_CATEGORY_NAME
))
.join('');
if (window.lucide) lucide.createIcons();
});
saveAsRecipeBtn?.addEventListener('click', async () => {
const title = panel.querySelector('#modal-title').value.trim();
if (!title) {
window.oikos?.showToast(t('meals.titleRequired'), 'error');
return;
}
const notes = panel.querySelector('#modal-notes').value.trim() || null;
const recipe_url = panel.querySelector('#modal-recipe-url').value.trim() || null;
const ingredients = collectModalIngredients(panel).map((ing) => ({
name: ing.name,
quantity: ing.quantity,
category: ing.category,
}));
saveAsRecipeBtn.disabled = true;
try {
const created = await api.post('/recipes', { title, notes, recipe_url, ingredients });
state.recipes.push(created.data);
if (recipeSelect) {
const option = document.createElement('option');
option.value = String(created.data.id);
option.textContent = created.data.title;
recipeSelect.appendChild(option);
recipeSelect.value = String(created.data.id);
}
window.oikos?.showToast(t('recipes.created'), 'success');
} catch (err) {
window.oikos?.showToast(err.data?.error ?? t('common.errorGeneric'), 'error');
} finally {
saveAsRecipeBtn.disabled = false;
}
});
if (presetRecipeId && recipeSelect) {
recipeSelect.value = String(presetRecipeId);
applyRecipe(presetRecipeId);
}
panel.querySelectorAll('.js-date-input').forEach((input) => {
input.addEventListener('blur', () => {
const parsed = parseDateInput(input.value);
if (parsed) input.value = formatDateInput(parsed);
});
});
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({ force: true });
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) =>
``
).join('');
const listOpts = state.lists.length
? state.lists.map((l) => ``).join('')
: ``;
const ingRows = isEdit && meal.ingredients?.length
? meal.ingredients.map((ing) => ingredientRowHTML(ing.name, ing.quantity ?? '', ing.id, ing.category ?? DEFAULT_CATEGORY_NAME)).join('')
: '';
const hasIngOpen = isEdit && meal.ingredients?.some((i) => !i.on_shopping_list);
const recipeOptions = [
``,
...state.recipes.map((r) => ``),
].join('');
return `
${isEdit && hasIngOpen ? `
` : ''}
`;
}
function ingredientRowHTML(name, qty, id, category = DEFAULT_CATEGORY_NAME) {
const availableCategories = mealCategories();
const resolvedCategory = availableCategories.some((c) => c.name === category)
? category
: (availableCategories[0]?.name ?? DEFAULT_CATEGORY_NAME);
const catOptions = availableCategories.length
? availableCategories.map((c) => ``).join('')
: ``;
return `
`;
}
function closeModal({ force = false } = {}) {
closeSharedModal({ force });
state.modal = null;
}
async function saveModal(overlay) {
const saveBtn = overlay.querySelector('#modal-save');
const dateRaw = overlay.querySelector('#modal-date').value;
const date = parseDateInput(dateRaw);
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;
const recipe_url = overlay.querySelector('#modal-recipe-url').value.trim() || null;
const recipe_id = overlay.querySelector('#modal-recipe-id')?.value || null;
if (!date || !isDateInputValid(dateRaw)) {
window.oikos?.showToast(t('calendar.invalidDate'), 'error');
return;
}
if (!title) {
window.oikos?.showToast(t('meals.titleRequired'), 'error');
return;
}
const ingredients = collectModalIngredients(overlay);
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, recipe_url, recipe_id, ingredients });
state.meals.push(res.data);
} else {
// Update meal meta
await api.put(`/meals/${meal.id}`, { date, meal_type, title, notes, recipe_url, recipe_id });
// 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, category: ing.category });
}
// Reload updated meal
await loadWeek(state.currentWeek);
}
closeModal({ force: true });
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');
}
}
function collectModalIngredients(overlay) {
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;
const category = row.querySelector('.ingredient-row__cat')?.value || DEFAULT_CATEGORY_NAME;
if (name) ingredients.push({ name, quantity: qty, category, id: row.dataset.ingId || null });
});
return ingredients;
}
// --------------------------------------------------------
// Mahlzeit löschen
// --------------------------------------------------------
async function deleteMeal(mealId) {
const meal = state.meals.find((m) => m.id === mealId);
const itemEl = _container.querySelector(`.meal-slot--has-meal[data-meal-id="${mealId}"]`);
if (itemEl) itemEl.style.display = 'none';
let undone = false;
window.oikos?.showToast(t('meals.deletedToast'), 'default', 5000, () => {
undone = true;
if (itemEl) itemEl.style.display = '';
});
setTimeout(async () => {
if (undone) return;
try {
await api.delete(`/meals/${mealId}`);
state.meals = state.meals.filter((m) => m.id !== mealId);
renderWeekGrid();
} catch (err) {
if (itemEl) itemEl.style.display = '';
window.oikos?.showToast(err.data?.error ?? t('common.unknownError'), 'danger');
}
}, 5000);
}
// --------------------------------------------------------
// 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
// --------------------------------------------------------