Files
Speisekarte/components/WeekEditor.tsx
T
florian.wilde 3d13ba88f4 Wochenplan: Gerichtsuche statt langer Dropdown-Liste, Beispieldaten zurück
WeekEditor: "Mahlzeit hinzufügen" zeigt jetzt ein Suchfeld mit
gefilterter Trefferliste (wie die Live-Suche bei Gerichten) statt einer
langen Dropdown-Liste zum Durchscrollen. Ausgewähltes Gericht lässt sich
per ×-Button wieder ändern.

Neues Skript db/seed-demo.sql (+ db/seed-demo.mjs, "npm run
db:seed-demo"): spielt den ursprünglichen Beispieldatensatz ein
(4 Essenszeiten, 14 Allergene, 11 Zusatzstoffe, 18 Testgerichte, zwei
Beispielwochen) – nur für lokale Entwicklung/Demos, kein Teil der
automatischen Migrationen, da frische Deployments weiterhin leer
starten sollen.
2026-07-18 11:35:13 +02:00

284 lines
10 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,
} from "@/lib/menu";
import { formatMessage } from "@/lib/i18n/shared";
import { localized } from "@/lib/types";
import type { Dish, MenuEntry, Section } from "@/lib/types";
import { useDict, useLocale } from "@/components/i18n/LocaleProvider";
export default function WeekEditor({ monday }: { monday: string }) {
const dict = useDict();
const locale = useLocale();
const friday = addDays(monday, 4);
const days = dict.weekdays.long.map((weekday, i) => ({
weekday,
iso: addDays(monday, i),
}));
const [sections, setSections] = useState<Section[]>([]);
const [dishes, setDishes] = useState<Dish[]>([]);
const [entries, setEntries] = useState<MenuEntry[]>([]);
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("");
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] = await Promise.all([
fetch("/api/sections"),
fetch("/api/dishes"),
fetch(`/api/menu-entries?from=${monday}&to=${friday}`),
]);
if (!se.ok || !di.ok || !en.ok) {
setError(formatMessage(dict.weekEditor.loadError, { message: "load failed" }));
} else {
setSections(await se.json());
setDishes(await di.json());
setEntries(await en.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();
}
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;
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>
)}
{loading ? (
<p className="text-gray-500">{dict.weekEditor.loading}</p>
) : (
<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)}
</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) => {
const isAdding =
adding?.iso === d.iso && adding?.section === s.key;
return (
<td key={d.iso} className="cell">
{entriesFor(entries, d.iso, s.key).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>
);
})}
{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(d.iso, s.key)}
>
{dict.weekEditor.addMeal}
</button>
)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
)}
<p className="mt-4 text-sm text-gray-500">
{hintBefore}
<Link href="/intern/gerichte" className="underline">
{dict.weekEditor.newDishesLinkLabel}
</Link>
{hintAfter}
</p>
</div>
);
}