Files
Speisekarte/components/WeekEditor.tsx
T
florian.wilde 2a2c5affd5 feat: Tage als geschlossen markierbar (Feiertage o. Ä.)
Im Wochenplan lässt sich pro Tag ein optionaler, mehrsprachiger
Schließungsgrund hinterlegen. Geschlossene Tage werden auf der
öffentlichen Speisekarte (Desktop + mobil) und im RSS-Feed als
"Geschlossen" statt mit Essenszeiten angezeigt, im Wochenplan bleibt
"Mahlzeit hinzufügen" für diesen Tag deaktiviert.
2026-07-20 12:35:00 +02:00

550 lines
19 KiB
TypeScript
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.
"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<Section[]>([]);
const [dishes, setDishes] = useState<Dish[]>([]);
const [entries, setEntries] = useState<MenuEntry[]>([]);
const [closedDays, setClosedDays] = useState<ClosedDay[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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<string | null>(null);
const [closeReason, setCloseReason] = useState("");
const [closeTranslations, setCloseTranslations] = useState<Record<string, string>>({});
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;
// Kleine Anzeige/Umschaltung unter dem Tagesdatum: "Geschlossen" +
// Wieder-öffnen-Button, sonst ein dezenter "Tag schließen"-Link.
function renderClosureBadge(iso: string) {
const closed = closedDayFor(iso);
if (closed) {
return (
<div className="mt-1 flex flex-wrap items-center justify-center gap-1 text-xs">
<span className="rounded-full bg-red-100 text-red-700 px-2 py-0.5 font-semibold">
{dict.weekEditor.closedLabel}
</span>
<button
type="button"
className="text-gray-400 hover:text-[var(--c-primary)] underline"
onClick={() => reopenDay(iso)}
disabled={closeBusy}
>
{dict.weekEditor.reopenDay}
</button>
</div>
);
}
return (
<button
type="button"
className="mt-1 text-xs text-gray-400 hover:text-[var(--c-primary)] underline"
onClick={() => startClosing(iso)}
>
{dict.weekEditor.closeDay}
</button>
);
}
// 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 (
<form
onSubmit={(e) => {
e.preventDefault();
saveClosing();
}}
className="card mb-4 space-y-3"
>
<h2 className="font-semibold">
{dict.weekEditor.closeDay}: {day?.weekday} ·{" "}
{formatDateLong(closingIso, dict.dateLocale)}
</h2>
<div>
<label className="label" htmlFor="closed-reason">
{dict.weekEditor.closedReasonLabel}
</label>
<input
id="closed-reason"
className="input"
placeholder={dict.weekEditor.closedReasonPlaceholder}
value={closeReason}
onChange={(e) => setCloseReason(e.target.value)}
/>
</div>
{languages.length > 0 && (
<div className="grid gap-3 sm:grid-cols-2">
{languages.map((lang) => (
<div key={lang.code}>
<label className="label" htmlFor={`closed-reason-${lang.code}`}>
{lang.label}
</label>
<input
id={`closed-reason-${lang.code}`}
className="input"
value={closeTranslations[lang.code] ?? ""}
onChange={(e) =>
setCloseTranslations({
...closeTranslations,
[lang.code]: e.target.value,
})
}
/>
</div>
))}
</div>
)}
{translateEnabled && languages.length > 0 && (
<div>
<button
type="button"
className="btn btn-neutral"
onClick={autoTranslateClosing}
disabled={closeTranslateState === "busy" || !closeReason.trim()}
>
{closeTranslateState === "busy"
? dict.dishManager.translating
: dict.dishManager.autoTranslate}
</button>
{closeTranslateState === "done" && (
<span className="ml-2 text-sm text-green-700">
{dict.dishManager.translated}
</span>
)}
</div>
)}
<div className="flex gap-2">
<button type="submit" className="btn btn-add" disabled={closeBusy}>
{dict.common.save}
</button>
<button type="button" className="btn btn-neutral" onClick={cancelClosing}>
{dict.common.cancel}
</button>
</div>
</form>
);
}
// 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;
const closed = Boolean(closedDayFor(iso));
return (
<>
{entriesFor(entries, iso, sectionKey).map((e) => {
const codes = dishCodes(e.dish);
return (
<div key={e.id} className="dish">
<span className="font-bold">{localized(e.dish, locale)}</span>
{codes && <sup className="dish-codes">{codes}</sup>}
{" – "}
{formatPrice(e.dish.price)}
<div className="mt-1">
<button
type="button"
className="btn btn-del"
onClick={() => removeEntry(e.id)}
>
{dict.common.delete}
</button>
</div>
</div>
);
})}
{closed ? null : isAdding ? (
<div className="mt-2 space-y-1 text-left">
{selectedDishObj ? (
<div className="flex items-center gap-1 text-sm">
<span className="mr-auto">
{localized(selectedDishObj, locale)} (
{formatPrice(selectedDishObj.price)})
</span>
<button
type="button"
className="text-gray-400 hover:text-[var(--c-primary)]"
onClick={() => {
setSelectedDish("");
setDishSearch("");
}}
aria-label={dict.weekEditor.changeDish}
title={dict.weekEditor.changeDish}
>
×
</button>
</div>
) : (
<>
<input
type="search"
className="input"
autoFocus
placeholder={dict.dishManager.searchPlaceholder}
value={dishSearch}
onChange={(e) => setDishSearch(e.target.value)}
/>
<ul className="max-h-32 overflow-y-auto border border-gray-200 rounded-md bg-white divide-y divide-gray-100">
{filteredDishes.map((dish) => (
<li key={dish.id}>
<button
type="button"
className="w-full text-left px-2 py-1 text-sm hover:bg-[var(--c-row-bg-alt)]"
onClick={() => setSelectedDish(dish.id)}
>
{localized(dish, locale)} ({formatPrice(dish.price)})
</button>
</li>
))}
{filteredDishes.length === 0 && (
<li className="px-2 py-1 text-sm text-gray-500">
{formatMessage(dict.dishManager.noResults, {
query: dishSearch,
})}
</li>
)}
</ul>
</>
)}
<div className="flex gap-1 justify-center">
<button
type="button"
className="btn btn-add"
onClick={addEntry}
disabled={!selectedDish}
>
{dict.common.add}
</button>
<button type="button" className="btn btn-neutral" onClick={cancelAdding}>
{dict.common.cancel}
</button>
</div>
</div>
) : (
<button
type="button"
className="btn btn-add mt-1"
onClick={() => startAdding(iso, sectionKey)}
>
{dict.weekEditor.addMeal}
</button>
)}
</>
);
}
const selectedDay = days[selectedDayIdx];
return (
<div>
<div className="flex flex-wrap items-center gap-3 mb-4">
<h1 className="text-xl font-bold text-[var(--c-primary)] mr-auto">
{dict.weekEditor.title}
</h1>
<Link href={`/intern?woche=${addDays(monday, -7)}`} className="btn btn-add">
{dict.publicMenu.prevWeek}
</Link>
<Link
href={`/intern?woche=${mondayOf(todayISO())}`}
className="btn btn-primary"
>
{dict.publicMenu.today}
</Link>
<Link href={`/intern?woche=${addDays(monday, 7)}`} className="btn btn-add">
{dict.publicMenu.nextWeek}
</Link>
</div>
{error && (
<p className="mb-3 text-sm text-[var(--c-del)]" role="alert">
{error}
</p>
)}
{renderClosingForm()}
{loading ? (
<p className="text-gray-500">{dict.weekEditor.loading}</p>
) : (
<>
{/* Mobil (< "sm"): ein Tag auf einmal, per Tabs gewählt */}
<div className="sm:hidden">
<div className="flex gap-1 mb-3">
{days.map((d, i) => (
<button
key={d.iso}
type="button"
onClick={() => setSelectedDayIdx(i)}
className={`flex-1 rounded-md py-1.5 text-sm font-semibold border transition-colors ${
i === selectedDayIdx
? "bg-[var(--c-primary)] text-white border-[var(--c-primary)]"
: "bg-white text-gray-600 border-gray-200"
} ${i === todayIdx ? "ring-2 ring-[var(--c-accent)] ring-offset-1" : ""}`}
>
{d.short}
</button>
))}
</div>
<h2 className="font-bold text-[var(--c-primary)] mb-1">
{selectedDay.weekday} · {formatDateLong(selectedDay.iso, dict.dateLocale)}
</h2>
<div className="mb-2">{renderClosureBadge(selectedDay.iso)}</div>
<div className="space-y-3">
{sections.map((s) => (
<div key={s.key} className="rounded-md border border-gray-200 overflow-hidden">
<div className="bg-[var(--c-primary)] text-[var(--c-header-text)] px-3 py-1.5 text-sm">
<span className="font-semibold">{localized(s, locale)}</span>{" "}
<span className="opacity-80">· {s.time_range}</span>
</div>
<div className="bg-[var(--c-row-bg)] px-3 py-2 text-sm text-center">
{renderCell(selectedDay.iso, s.key)}
</div>
</div>
))}
</div>
</div>
{/* Ab "sm": klassische Wochentabelle */}
<div className="hidden sm:block">
<table className="menu-table">
<thead>
<tr>
<th className="section-head w-52" aria-hidden />
{days.map((d) => (
<th key={d.iso} className="day-head">
<span className="font-bold">{d.weekday}</span>
<br />
{formatDateLong(d.iso, dict.dateLocale)}
{renderClosureBadge(d.iso)}
</th>
))}
</tr>
</thead>
<tbody>
{sections.map((s) => (
<tr key={s.key}>
<th className="section-head">
<div>{localized(s, locale)}</div>
<div className="time">{s.time_range}</div>
</th>
{days.map((d) => (
<td key={d.iso} className="cell">
{renderCell(d.iso, s.key)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</>
)}
<p className="mt-4 text-sm text-gray-500">
{hintBefore}
<Link href="/intern/gerichte" className="underline">
{dict.weekEditor.newDishesLinkLabel}
</Link>
{hintAfter}
</p>
</div>
);
}