Files
oikos/public/pages/meals.js
T
Ulas 44e5a879b9 fix(ux): replace native confirm() dialogs, add undo-toast, fix prefers-reduced-motion
- Replace all 13 native confirm() calls with confirmModal() across 7 page modules
- Add confirmModal() to modal.js (Promise-based, danger variant, focus management)
- Fix double-confirm bug in contacts.js and budget.js (modal + deleteContact/deleteEntry)
- Extend showToast() with onUndo callback and max-3-toast limit
- Implement optimistic undo-toast (4s window) for shopping item and bulk-checked delete
- Add prefers-reduced-motion guard to btnSuccess() and btnError() in modal.js
- Add btn--error-static CSS class as motion-reduced fallback for btnError()
- Add toast__undo button styles to layout.css
- Add common.confirm and common.undo i18n keys (de, en, it, sv)
- Add shopping.itemDeletedToast i18n key (de, en, it, sv)
2026-04-05 12:31:16 +02:00

732 lines
27 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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, confirmModal } 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,
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);
}
// --------------------------------------------------------
// 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 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 = `
<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(), loadPreferences()]);
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().filter((type) => state.visibleMealTypes.includes(type.key)).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 (!await confirmModal(t('meals.deleteMeal') + '?', { danger: true, confirmLabel: t('common.delete') })) 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
// --------------------------------------------------------