Kantinen-Bereich: Wochenplan-Editor, Gerichte, Kennzeichnungen, Passkey-Verwaltung

This commit is contained in:
2026-07-17 18:18:41 +02:00
parent d2bb39d815
commit 76f90d519c
9 changed files with 780 additions and 0 deletions
+212
View File
@@ -0,0 +1,212 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { createClient } from "@/lib/supabase/client";
import { addDays, formatDateLong, formatPrice, WEEKDAYS } from "@/lib/menu";
import type { Dish, MenuEntry, Section } from "@/lib/types";
export default function WeekEditor({ monday }: { monday: string }) {
const supabase = useMemo(() => createClient(), []);
const friday = addDays(monday, 4);
const days = WEEKDAYS.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 load = useCallback(async () => {
setError(null);
const [se, di, en] = await Promise.all([
supabase.from("sections").select("*").order("sort"),
supabase.from("dishes").select("*").order("name"),
supabase
.from("menu_entries")
.select("id, menu_date, section_key, position, dish:dishes(id, name, price)")
.gte("menu_date", monday)
.lte("menu_date", friday)
.order("position")
.order("created_at"),
]);
const problem = se.error ?? di.error ?? en.error;
if (problem) {
setError(`Laden fehlgeschlagen: ${problem.message}`);
} else {
setSections(se.data as Section[]);
setDishes(di.data as Dish[]);
setEntries(en.data as unknown as MenuEntry[]);
}
setLoading(false);
}, [supabase, monday, friday]);
useEffect(() => {
setLoading(true);
load();
}, [load]);
async function addEntry() {
if (!adding || !selectedDish) return;
const { error } = await supabase.from("menu_entries").insert({
menu_date: adding.iso,
section_key: adding.section,
dish_id: selectedDish,
});
if (error) {
setError(
/duplicate key/i.test(error.message)
? "Dieses Gericht ist an diesem Tag bereits zugewiesen."
: `Speichern fehlgeschlagen: ${error.message}`
);
}
setAdding(null);
setSelectedDish("");
await load();
}
async function removeEntry(id: string) {
const { error } = await supabase.from("menu_entries").delete().eq("id", id);
if (error) setError(`Löschen fehlgeschlagen: ${error.message}`);
await load();
}
const cell = (iso: string, sectionKey: string) =>
entries.filter((e) => e.menu_date === iso && e.section_key === sectionKey);
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">
Wochenplan
</h1>
<Link href={`/intern?woche=${addDays(monday, -7)}`} className="btn btn-add">
← Vorherige Woche
</Link>
<Link href={`/intern?woche=${addDays(monday, 7)}`} className="btn btn-add">
Nächste Woche →
</Link>
</div>
{error && (
<p className="mb-3 text-sm text-[var(--c-del)]" role="alert">
{error}
</p>
)}
{loading ? (
<p className="text-gray-500">Lade Wochenplan…</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)}
</th>
))}
</tr>
</thead>
<tbody>
{sections.map((s) => (
<tr key={s.key}>
<th className="section-head">
<div>{s.label}</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">
{cell(d.iso, s.key).map((e) => (
<div key={e.id} className="dish">
<span className="font-bold">{e.dish.name}</span>
{" – "}
{formatPrice(e.dish.price)}
<div className="mt-1">
<button
type="button"
className="btn btn-del"
onClick={() => removeEntry(e.id)}
>
Löschen
</button>
</div>
</div>
))}
{isAdding ? (
<div className="mt-2 space-y-1 text-left">
<select
className="input"
value={selectedDish}
onChange={(e) => setSelectedDish(e.target.value)}
>
<option value="">– Gericht wählen –</option>
{dishes.map((dish) => (
<option key={dish.id} value={dish.id}>
{dish.name} ({formatPrice(dish.price)})
</option>
))}
</select>
<div className="flex gap-1 justify-center">
<button
type="button"
className="btn btn-add"
onClick={addEntry}
disabled={!selectedDish}
>
Hinzufügen
</button>
<button
type="button"
className="btn btn-neutral"
onClick={() => {
setAdding(null);
setSelectedDish("");
}}
>
Abbrechen
</button>
</div>
</div>
) : (
<button
type="button"
className="btn btn-add mt-1"
onClick={() => {
setAdding({ iso: d.iso, section: s.key });
setSelectedDish("");
}}
>
Mahlzeit hinzufügen
</button>
)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
)}
<p className="mt-4 text-sm text-gray-500">
Neue Gerichte legst du unter{" "}
<Link href="/intern/gerichte" className="underline">
Gerichte
</Link>{" "}
an.
</p>
</div>
);
}