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 (
+
+ );
+}
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}
+
+ setEditing({ original: null, code: "", name: "" })}
+ >
+ Hinzufügen
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {editing && (
+
+ )}
+
+
+ {items.map((item) => (
+
+
+ {item.code} – {item.name}
+
+
+ setEditing({
+ original: item.code,
+ code: item.code,
+ name: item.name,
+ })
+ }
+ >
+ Bearbeiten
+
+ remove(item)}
+ >
+ Löschen
+
+
+ ))}
+ {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
+
+ setForm(EMPTY_FORM)}
+ >
+ Neues Gericht
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {form && (
+
+ )}
+
+ {loading ? (
+
Lade Gerichte…
+ ) : (
+
+ {dishes.map((dish) => (
+
+
+ {dish.name} {" "}
+ – {formatPrice(dish.price)}
+
+
+ setForm({
+ id: dish.id,
+ name: dish.name,
+ price: String(dish.price).replace(".", ","),
+ })
+ }
+ >
+ Bearbeiten
+
+ remove(dish)}
+ >
+ Löschen
+
+
+ ))}
+ {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}
}
+
+
+ Neuen Passkey registrieren
+
+
+
+ {passkeys.map((pk) => (
+
+
+
+ {pk.friendly_name || "Passkey"}
+ {" "}
+
+ – angelegt am{" "}
+ {new Date(pk.created_at).toLocaleDateString("de-DE")}
+
+
+ remove(pk)}
+ >
+ Löschen
+
+
+ ))}
+ {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…
+ ) : (
+
+
+
+
+ {days.map((d) => (
+
+ {d.weekday}
+
+ {formatDateLong(d.iso)}
+
+ ))}
+
+
+
+ {sections.map((s) => (
+
+
+ {s.label}
+ {s.time_range}
+
+ {days.map((d) => {
+ const isAdding =
+ adding?.iso === d.iso && adding?.section === s.key;
+ return (
+
+ {cell(d.iso, s.key).map((e) => (
+
+
{e.dish.name}
+ {" – "}
+ {formatPrice(e.dish.price)}
+
+ removeEntry(e.id)}
+ >
+ Löschen
+
+
+
+ ))}
+ {isAdding ? (
+
+
setSelectedDish(e.target.value)}
+ >
+ – Gericht wählen –
+ {dishes.map((dish) => (
+
+ {dish.name} ({formatPrice(dish.price)})
+
+ ))}
+
+
+
+ Hinzufügen
+
+ {
+ setAdding(null);
+ setSelectedDish("");
+ }}
+ >
+ Abbrechen
+
+
+
+ ) : (
+ {
+ setAdding({ iso: d.iso, section: s.key });
+ setSelectedDish("");
+ }}
+ >
+ Mahlzeit hinzufügen
+
+ )}
+
+ );
+ })}
+
+ ))}
+
+
+ )}
+
+ Neue Gerichte legst du unter{" "}
+
+ Gerichte
+ {" "}
+ an.
+
+
+ );
+}