"use client"; import { useCallback, useEffect, useState } from "react"; import Link from "next/link"; import { addDays, dishCodes, entriesFor, formatDateLong, formatPrice, mondayOf, todayISO, weekdayIndex, } from "@/lib/menu"; import { formatMessage } from "@/lib/i18n/shared"; import { localized } from "@/lib/types"; import type { ClosedDay, Dish, MenuEntry, Section } from "@/lib/types"; import { useDict, useLanguages, useLocale } from "@/components/i18n/LocaleProvider"; export default function WeekEditor({ monday, translateEnabled, }: { monday: string; translateEnabled: boolean; }) { const dict = useDict(); const locale = useLocale(); const languages = useLanguages().filter((l) => l.code !== "de"); const friday = addDays(monday, 4); const days = dict.weekdays.long.map((weekday, i) => ({ weekday, short: dict.weekdays.short[i], iso: addDays(monday, i), })); const isCurrentWeek = monday === mondayOf(todayISO()); const todayIdx = isCurrentWeek ? weekdayIndex(todayISO()) : -1; const [sections, setSections] = useState([]); const [dishes, setDishes] = useState([]); const [entries, setEntries] = useState([]); const [closedDays, setClosedDays] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); // Zelle, in der gerade ein Gericht hinzugefügt wird const [adding, setAdding] = useState<{ iso: string; section: string } | null>( null ); const [selectedDish, setSelectedDish] = useState(""); const [dishSearch, setDishSearch] = useState(""); // Mobil: ein Tag auf einmal statt aller fünf gleichzeitig const [selectedDayIdx, setSelectedDayIdx] = useState(todayIdx >= 0 ? todayIdx : 0); // Tag, für den gerade das Schließen-Formular offen ist const [closingIso, setClosingIso] = useState(null); const [closeReason, setCloseReason] = useState(""); const [closeTranslations, setCloseTranslations] = useState>({}); const [closeTranslateState, setCloseTranslateState] = useState<"idle" | "busy" | "done">( "idle" ); const [closeBusy, setCloseBusy] = useState(false); function closedDayFor(iso: string): ClosedDay | undefined { return closedDays.find((c) => c.closed_date === iso); } function startAdding(iso: string, section: string) { setAdding({ iso, section }); setSelectedDish(""); setDishSearch(""); } function cancelAdding() { setAdding(null); setSelectedDish(""); setDishSearch(""); } const load = useCallback(async () => { setError(null); const [se, di, en, cd] = await Promise.all([ fetch("/api/sections"), fetch("/api/dishes"), fetch(`/api/menu-entries?from=${monday}&to=${friday}`), fetch(`/api/closed-days?from=${monday}&to=${friday}`), ]); if (!se.ok || !di.ok || !en.ok || !cd.ok) { setError(formatMessage(dict.weekEditor.loadError, { message: "load failed" })); } else { setSections(await se.json()); setDishes(await di.json()); setEntries(await en.json()); setClosedDays(await cd.json()); } setLoading(false); }, [monday, friday, dict]); useEffect(() => { setLoading(true); load(); }, [load]); async function addEntry() { if (!adding || !selectedDish) return; const res = await fetch("/api/menu-entries", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ menu_date: adding.iso, section_key: adding.section, dish_id: selectedDish, }), }); if (!res.ok) { const data = await res.json().catch(() => ({})); setError( data.error === "duplicate_entry" ? dict.weekEditor.duplicateEntry : formatMessage(dict.weekEditor.saveError, { message: data.error ?? String(res.status) }) ); } cancelAdding(); await load(); } async function removeEntry(id: string) { const res = await fetch(`/api/menu-entries/${id}`, { method: "DELETE" }); if (!res.ok) setError(formatMessage(dict.weekEditor.deleteError, { message: String(res.status) })); await load(); } function startClosing(iso: string) { const existing = closedDayFor(iso); setClosingIso(iso); setCloseReason(existing?.reason ?? ""); setCloseTranslations({ ...(existing?.translations ?? {}) }); setCloseTranslateState("idle"); } function cancelClosing() { setClosingIso(null); } async function saveClosing() { if (!closingIso) return; setCloseBusy(true); setError(null); const res = await fetch("/api/closed-days", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ closed_date: closingIso, reason: closeReason.trim() || null, translations: closeTranslations, }), }); setCloseBusy(false); if (!res.ok) { setError(formatMessage(dict.weekEditor.saveError, { message: String(res.status) })); return; } setClosingIso(null); await load(); } async function reopenDay(iso: string) { setCloseBusy(true); setError(null); const res = await fetch(`/api/closed-days/${iso}`, { method: "DELETE" }); setCloseBusy(false); if (!res.ok) { setError(formatMessage(dict.weekEditor.deleteError, { message: String(res.status) })); return; } if (closingIso === iso) setClosingIso(null); await load(); } async function autoTranslateClosing() { if (!closingIso || !closeReason.trim()) return; setCloseTranslateState("busy"); setError(null); try { const res = await fetch("/api/translate", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ table: "closed_days", id: closedDayFor(closingIso) ? closingIso : null, text: closeReason.trim(), }), }); const data = await res.json(); if (!res.ok) throw new Error(data?.error ?? `Fehler (${res.status})`); setCloseTranslations((prev) => ({ ...prev, ...(data.translations ?? {}) })); setCloseTranslateState("done"); } catch (err) { setError(err instanceof Error ? err.message : String(err)); setCloseTranslateState("idle"); } } const [hintBefore, hintAfter] = dict.weekEditor.newDishesHint.split("{link}"); const selectedDishObj = dishes.find((d) => d.id === selectedDish) ?? null; const query = dishSearch.trim().toLowerCase(); const filteredDishes = query ? dishes.filter((d) => localized(d, locale).toLowerCase().includes(query)) : dishes; // Kleines Schloss-Symbol neben dem Tagesdatum: 🔓 zum Schließen // anklicken, 🔒 zeigt einen geschlossenen Tag und öffnet ihn per Klick // wieder – der Zustand des Symbols selbst ist die Beschriftung. function renderClosureToggle(iso: string) { const closed = Boolean(closedDayFor(iso)); return ( ); } // Banner, das bei einem geschlossenen Tag alle Essenszeiten-Zellen // dieses Tages ersetzt (Desktop: eine über rowSpan verbundene Zelle, // Mobil: anstelle der Essenszeiten-Karten) – "Bearbeiten" öffnet das // Formular erneut, um nur den Grund anzupassen, ohne den Tag über das // Symbol im Header erst wieder öffnen zu müssen. function renderClosedBanner(iso: string, closed: ClosedDay) { return (
🔒 {dict.weekEditor.closedLabel} {closed.reason && {closed.reason}}
); } // Formular zum Schließen/Bearbeiten eines Tages – ein gemeinsames // Formular für Desktop-Tabelle und mobile Tagesansicht, da immer nur // ein Tag gleichzeitig bearbeitet werden kann. function renderClosingForm() { if (!closingIso) return null; const day = days.find((d) => d.iso === closingIso); return (
{ e.preventDefault(); saveClosing(); }} className="card mb-4 space-y-3" >

{dict.weekEditor.closeDay}: {day?.weekday} ·{" "} {formatDateLong(closingIso, dict.dateLocale)}

setCloseReason(e.target.value)} />
{languages.length > 0 && (
{languages.map((lang) => (
setCloseTranslations({ ...closeTranslations, [lang.code]: e.target.value, }) } />
))}
)} {translateEnabled && languages.length > 0 && (
{closeTranslateState === "done" && ( {dict.dishManager.translated} )}
)}
); } // Inhalt einer Tag/Essenszeit-Zelle: Eintragsliste + "hinzufügen"-Flow. // Identisches Fragment für Desktop-Tabelle und Mobile-Karten, damit der // Bearbeitungs-Zustand (adding/selectedDish/dishSearch) nicht dupliziert // werden muss – nur eine Zelle kann gleichzeitig bearbeitet werden, // unabhängig davon, welche Ansicht gerade rendert. function renderCell(iso: string, sectionKey: string) { const isAdding = adding?.iso === iso && adding?.section === sectionKey; return ( <> {entriesFor(entries, iso, sectionKey).map((e) => { const codes = dishCodes(e.dish); return (
{localized(e.dish, locale)} {codes && {codes}} {" – "} {formatPrice(e.dish.price)}
); })} {isAdding ? (
{selectedDishObj ? (
{localized(selectedDishObj, locale)} ( {formatPrice(selectedDishObj.price)})
) : ( <> setDishSearch(e.target.value)} />
    {filteredDishes.map((dish) => (
  • ))} {filteredDishes.length === 0 && (
  • {formatMessage(dict.dishManager.noResults, { query: dishSearch, })}
  • )}
)}
) : ( )} ); } const selectedDay = days[selectedDayIdx]; return (

{dict.weekEditor.title}

{dict.publicMenu.prevWeek} {dict.publicMenu.today} {dict.publicMenu.nextWeek}
{error && (

{error}

)} {renderClosingForm()} {loading ? (

{dict.weekEditor.loading}

) : ( <> {/* Mobil (< "sm"): ein Tag auf einmal, per Tabs gewählt */}
{days.map((d, i) => ( ))}

{selectedDay.weekday} · {formatDateLong(selectedDay.iso, dict.dateLocale)}{" "} {renderClosureToggle(selectedDay.iso)}

{(() => { const closed = closedDayFor(selectedDay.iso); if (closed) { return (
{renderClosedBanner(selectedDay.iso, closed)}
); } return (
{sections.map((s) => (
{localized(s, locale)}{" "} · {s.time_range}
{renderCell(selectedDay.iso, s.key)}
))}
); })()}
{/* Ab "sm": klassische Wochentabelle */}
))} {sections.map((s, sIdx) => ( {days.map((d) => { const closed = closedDayFor(d.iso); if (closed) { if (sIdx > 0) return null; return ( ); } return ( ); })} ))}
{days.map((d) => ( {d.weekday}
{formatDateLong(d.iso, dict.dateLocale)} {renderClosureToggle(d.iso)}
{localized(s, locale)}
{s.time_range}
{renderClosedBanner(d.iso, closed)} {renderCell(d.iso, s.key)}
)}

{hintBefore} {dict.weekEditor.newDishesLinkLabel} {hintAfter}

); }