From 76f90d519c40213af9efbd0b1ab7854d02d4740c Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Fri, 17 Jul 2026 18:18:41 +0200 Subject: [PATCH] Kantinen-Bereich: Wochenplan-Editor, Gerichte, Kennzeichnungen, Passkey-Verwaltung --- app/intern/gerichte/page.tsx | 5 + app/intern/kennzeichnung/page.tsx | 18 +++ app/intern/konto/page.tsx | 5 + app/intern/layout.tsx | 67 ++++++++++ app/intern/page.tsx | 11 ++ components/CodeListManager.tsx | 168 +++++++++++++++++++++++ components/DishManager.tsx | 178 +++++++++++++++++++++++++ components/PasskeyManager.tsx | 116 ++++++++++++++++ components/WeekEditor.tsx | 212 ++++++++++++++++++++++++++++++ 9 files changed, 780 insertions(+) create mode 100644 app/intern/gerichte/page.tsx create mode 100644 app/intern/kennzeichnung/page.tsx create mode 100644 app/intern/konto/page.tsx create mode 100644 app/intern/layout.tsx create mode 100644 app/intern/page.tsx create mode 100644 components/CodeListManager.tsx create mode 100644 components/DishManager.tsx create mode 100644 components/PasskeyManager.tsx create mode 100644 components/WeekEditor.tsx diff --git a/app/intern/gerichte/page.tsx b/app/intern/gerichte/page.tsx new file mode 100644 index 0000000..96de7e9 --- /dev/null +++ b/app/intern/gerichte/page.tsx @@ -0,0 +1,5 @@ +import DishManager from "@/components/DishManager"; + +export default function GerichtePage() { + return ; +} diff --git a/app/intern/kennzeichnung/page.tsx b/app/intern/kennzeichnung/page.tsx new file mode 100644 index 0000000..69971f6 --- /dev/null +++ b/app/intern/kennzeichnung/page.tsx @@ -0,0 +1,18 @@ +import CodeListManager from "@/components/CodeListManager"; + +export default function KennzeichnungPage() { + return ( +
+ + +
+ ); +} diff --git a/app/intern/konto/page.tsx b/app/intern/konto/page.tsx new file mode 100644 index 0000000..b5cfed6 --- /dev/null +++ b/app/intern/konto/page.tsx @@ -0,0 +1,5 @@ +import PasskeyManager from "@/components/PasskeyManager"; + +export default function KontoPage() { + return ; +} diff --git a/app/intern/layout.tsx b/app/intern/layout.tsx new file mode 100644 index 0000000..476d12c --- /dev/null +++ b/app/intern/layout.tsx @@ -0,0 +1,67 @@ +import Link from "next/link"; +import { redirect } from "next/navigation"; +import { createClient } from "@/lib/supabase/server"; + +export default async function InternLayout({ + children, +}: { + children: React.ReactNode; +}) { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) redirect("/login"); + + const { data: profile } = await supabase + .from("profiles") + .select("role, full_name") + .eq("id", user.id) + .single(); + if (!profile || !["staff", "admin"].includes(profile.role)) redirect("/"); + + return ( +
+
+
+ Kantinen-Bereich + +
+ + {profile.full_name || user.email} + +
+ +
+
+
+
+
+ {children} +
+
+ ); +} diff --git a/app/intern/page.tsx b/app/intern/page.tsx new file mode 100644 index 0000000..abb45bc --- /dev/null +++ b/app/intern/page.tsx @@ -0,0 +1,11 @@ +import { mondayOf } from "@/lib/menu"; +import WeekEditor from "@/components/WeekEditor"; + +export default async function InternPage({ + searchParams, +}: { + searchParams: Promise<{ woche?: string }>; +}) { + const { woche } = await searchParams; + return ; +} diff --git a/components/CodeListManager.tsx b/components/CodeListManager.tsx new file mode 100644 index 0000000..7c4e962 --- /dev/null +++ b/components/CodeListManager.tsx @@ -0,0 +1,168 @@ +"use client"; + +// Pflege der globalen Allergen-/Zusatzstofflisten, die unterhalb +// der öffentlichen Speisekarte angezeigt werden. + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { createClient } from "@/lib/supabase/client"; +import type { CodeItem } from "@/lib/types"; + +export default function CodeListManager({ + table, + title, + codeLabel, +}: { + table: "allergens" | "additives"; + title: string; + codeLabel: string; +}) { + const supabase = useMemo(() => createClient(), []); + const [items, setItems] = useState([]); + const [editing, setEditing] = useState<{ + original: string | null; // code vor der Bearbeitung, null = neu + code: string; + name: string; + } | null>(null); + const [error, setError] = useState(null); + + const load = useCallback(async () => { + const { data, error } = await supabase.from(table).select("*").order("sort"); + if (error) setError(`Laden fehlgeschlagen: ${error.message}`); + else setItems(data as CodeItem[]); + }, [supabase, table]); + + useEffect(() => { + load(); + }, [load]); + + async function save(e: React.FormEvent) { + e.preventDefault(); + if (!editing) return; + setError(null); + const code = editing.code.trim(); + const name = editing.name.trim(); + if (!code || !name) return; + + let dbError; + if (editing.original) { + ({ error: dbError } = await supabase + .from(table) + .update({ code, name }) + .eq("code", editing.original)); + } else { + const maxSort = items.reduce((m, i) => Math.max(m, i.sort), 0); + ({ error: dbError } = await supabase + .from(table) + .insert({ code, name, sort: maxSort + 1 })); + } + if (dbError) { + setError( + /duplicate key/i.test(dbError.message) + ? `Das Kürzel „${code}“ existiert bereits.` + : `Speichern fehlgeschlagen: ${dbError.message}` + ); + return; + } + setEditing(null); + await load(); + } + + async function remove(item: CodeItem) { + if (!window.confirm(`„${item.code} – ${item.name}“ wirklich löschen?`)) + return; + const { error } = await supabase.from(table).delete().eq("code", item.code); + if (error) setError(`Löschen fehlgeschlagen: ${error.message}`); + await load(); + } + + return ( +
+
+

+ {title} +

+ +
+ + {error && ( +

+ {error} +

+ )} + + {editing && ( +
+
+ + setEditing({ ...editing, code: e.target.value })} + required + /> +
+
+ + setEditing({ ...editing, name: e.target.value })} + required + /> +
+
+ + +
+
+ )} + +
    + {items.map((item) => ( +
  • + + {item.code} – {item.name} + + + +
  • + ))} + {items.length === 0 && ( +
  • Keine Einträge.
  • + )} +
+
+ ); +} diff --git a/components/DishManager.tsx b/components/DishManager.tsx new file mode 100644 index 0000000..d672956 --- /dev/null +++ b/components/DishManager.tsx @@ -0,0 +1,178 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { createClient } from "@/lib/supabase/client"; +import { formatPrice } from "@/lib/menu"; +import type { Dish } from "@/lib/types"; + +interface DishForm { + id: string | null; // null = neues Gericht + name: string; + price: string; +} + +const EMPTY_FORM: DishForm = { id: null, name: "", price: "" }; + +export default function DishManager() { + const supabase = useMemo(() => createClient(), []); + const [dishes, setDishes] = useState([]); + const [form, setForm] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + + const load = useCallback(async () => { + const { data, error } = await supabase + .from("dishes") + .select("*") + .order("name"); + if (error) setError(`Laden fehlgeschlagen: ${error.message}`); + else setDishes(data as Dish[]); + setLoading(false); + }, [supabase]); + + useEffect(() => { + load(); + }, [load]); + + async function save(e: React.FormEvent) { + e.preventDefault(); + if (!form) return; + setError(null); + const price = Number.parseFloat(form.price.replace(",", ".")); + if (Number.isNaN(price) || price < 0) { + setError("Bitte einen gültigen Preis angeben, z. B. 3,50"); + return; + } + const payload = { name: form.name.trim(), price }; + const { error } = form.id + ? await supabase.from("dishes").update(payload).eq("id", form.id) + : await supabase.from("dishes").insert(payload); + if (error) { + setError(`Speichern fehlgeschlagen: ${error.message}`); + return; + } + setForm(null); + await load(); + } + + async function remove(dish: Dish) { + if ( + !window.confirm( + `„${dish.name}“ wirklich löschen? Das Gericht wird auch aus allen Wochenplänen entfernt.` + ) + ) + return; + const { error } = await supabase.from("dishes").delete().eq("id", dish.id); + if (error) setError(`Löschen fehlgeschlagen: ${error.message}`); + await load(); + } + + return ( +
+
+

+ Gerichte +

+ +
+ + {error && ( +

+ {error} +

+ )} + + {form && ( +
+

+ {form.id ? "Gericht bearbeiten" : "Neues Gericht"} +

+
+ + setForm({ ...form, name: e.target.value })} + required + /> +
+
+ + setForm({ ...form, price: e.target.value })} + required + /> +
+
+ + +
+
+ )} + + {loading ? ( +

Lade Gerichte…

+ ) : ( +
    + {dishes.map((dish) => ( +
  • + + {dish.name}{" "} + – {formatPrice(dish.price)} + + + +
  • + ))} + {dishes.length === 0 && ( +
  • + Noch keine Gerichte angelegt. +
  • + )} +
+ )} +
+ ); +} diff --git a/components/PasskeyManager.tsx b/components/PasskeyManager.tsx new file mode 100644 index 0000000..2ec75e4 --- /dev/null +++ b/components/PasskeyManager.tsx @@ -0,0 +1,116 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { createClient } from "@/lib/supabase/client"; + +interface PasskeyItem { + id: string; + friendly_name?: string; + created_at: string; + last_used_at?: string; +} + +export default function PasskeyManager() { + const supabase = useMemo(() => createClient(), []); + const [passkeys, setPasskeys] = useState([]); + const [error, setError] = useState(null); + const [info, setInfo] = useState(null); + const [busy, setBusy] = useState(false); + + const load = useCallback(async () => { + const { data, error } = await supabase.auth.passkey.list(); + if (error) setError(`Passkeys konnten nicht geladen werden: ${error.message}`); + else setPasskeys((data ?? []) as PasskeyItem[]); + }, [supabase]); + + useEffect(() => { + load(); + }, [load]); + + async function register() { + setError(null); + setInfo(null); + setBusy(true); + try { + const { error } = await supabase.auth.registerPasskey(); + if (error) { + setError(`Registrierung fehlgeschlagen: ${error.message}`); + } else { + setInfo("Passkey wurde erfolgreich registriert."); + await load(); + } + } catch (err) { + setError( + /NotAllowedError|abort/i.test(String(err)) + ? "Die Passkey-Registrierung wurde abgebrochen." + : `Registrierung fehlgeschlagen: ${err instanceof Error ? err.message : String(err)}` + ); + } + setBusy(false); + } + + async function remove(pk: PasskeyItem) { + if (!window.confirm("Diesen Passkey wirklich löschen?")) return; + const { error } = await supabase.auth.passkey.delete({ passkeyId: pk.id }); + if (error) setError(`Löschen fehlgeschlagen: ${error.message}`); + await load(); + } + + return ( +
+

+ Passkeys +

+

+ Mit einem Passkey (Fingerabdruck, Gesichtserkennung oder + Sicherheitsschlüssel) kannst du dich künftig ohne Passwort anmelden. + Passkeys gelten pro Domain – für den Live-Betrieb muss die Domain in + der Supabase-Konfiguration hinterlegt sein. +

+ + {error && ( +

+ {error} +

+ )} + {info &&

{info}

} + + + +
    + {passkeys.map((pk) => ( +
  • + + + {pk.friendly_name || "Passkey"} + {" "} + + – angelegt am{" "} + {new Date(pk.created_at).toLocaleDateString("de-DE")} + + + +
  • + ))} + {passkeys.length === 0 && ( +
  • + Noch keine Passkeys registriert. +
  • + )} +
+
+ ); +} diff --git a/components/WeekEditor.tsx b/components/WeekEditor.tsx new file mode 100644 index 0000000..267a055 --- /dev/null +++ b/components/WeekEditor.tsx @@ -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([]); + const [dishes, setDishes] = useState([]); + const [entries, setEntries] = 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 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 ( +
+
+

+ Wochenplan +

+ + ← Vorherige Woche + + + Nächste Woche → + +
+ + {error && ( +

+ {error} +

+ )} + {loading ? ( +

Lade Wochenplan…

+ ) : ( + + + + + ))} + + + + {sections.map((s) => ( + + + {days.map((d) => { + const isAdding = + adding?.iso === d.iso && adding?.section === s.key; + return ( + + ); + })} + + ))} + +
+ {days.map((d) => ( + + {d.weekday} +
+ {formatDateLong(d.iso)} +
+
{s.label}
+
{s.time_range}
+
+ {cell(d.iso, s.key).map((e) => ( +
+ {e.dish.name} + {" – "} + {formatPrice(e.dish.price)} +
+ +
+
+ ))} + {isAdding ? ( +
+ +
+ + +
+
+ ) : ( + + )} +
+ )} +

+ Neue Gerichte legst du unter{" "} + + Gerichte + {" "} + an. +

+
+ ); +}