Files
Speisekarte/components/WeekEditor.tsx
T
florian.wildeandClaude Sonnet 5 0c69a21a85 feat: interner Bereich für Mobilgeräte nutzbar
AppHeader.tsx: Hamburger-Menü unterhalb "sm" mit denselben Links
gestapelt (Vorschläge-Badge-Logik in gemeinsame renderLinks()-Funktion
ausgelagert), Panel schließt automatisch bei Navigation. Bisherige
einzeilige Navigation bleibt ab "sm" unverändert.

WeekEditor.tsx: Mobile Tag-für-Tag-Ansicht mit Tab-Leiste (Mo-Fr)
statt der 6-spaltigen Tabelle, die bei 375px unbenutzbar gequetscht
war. Zellinhalt (Eintragsliste + "hinzufügen"-Flow) in renderCell()
extrahiert und von Tabelle und Karten-Ansicht gemeinsam genutzt, damit
der Bearbeitungs-Zustand nicht dupliziert werden muss. Desktop-Tabelle
unverändert ab "sm".

DishManager/CodeListManager/UserManager/SectionManager/PasskeyManager:
Kopf- und Listenzeilen brechen jetzt bei Bedarf um (flex-wrap) statt
Buttons abzuschneiden, gleiches Muster wie bereits in
SuggestionReview.tsx vorhanden.

Version 1.9.5.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 00:54:05 +02:00

337 lines
12 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 { 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,
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 [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);
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;
// 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 (
<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(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>
)}
{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-2">
{selectedDay.weekday} · {formatDateLong(selectedDay.iso, dict.dateLocale)}
</h2>
<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)}
</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>
);
}