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
+5
View File
@@ -0,0 +1,5 @@
import DishManager from "@/components/DishManager";
export default function GerichtePage() {
return <DishManager />;
}
+18
View File
@@ -0,0 +1,18 @@
import CodeListManager from "@/components/CodeListManager";
export default function KennzeichnungPage() {
return (
<div className="grid gap-6 lg:grid-cols-2">
<CodeListManager
table="allergens"
title="Allergene"
codeLabel="Kürzel (z. B. A)"
/>
<CodeListManager
table="additives"
title="Zusatzstoffe"
codeLabel="Kürzel (z. B. 1)"
/>
</div>
);
}
+5
View File
@@ -0,0 +1,5 @@
import PasskeyManager from "@/components/PasskeyManager";
export default function KontoPage() {
return <PasskeyManager />;
}
+67
View File
@@ -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 (
<div className="flex-1 flex flex-col">
<header className="bg-[var(--c-primary-dark)] text-white">
<div className="max-w-[1600px] mx-auto w-full px-4 py-3 flex flex-wrap items-center gap-x-5 gap-y-2">
<span className="font-bold">Kantinen-Bereich</span>
<nav className="flex flex-wrap gap-x-4 gap-y-1 text-sm">
<Link href="/intern" className="hover:underline">
Wochenplan
</Link>
<Link href="/intern/gerichte" className="hover:underline">
Gerichte
</Link>
<Link href="/intern/kennzeichnung" className="hover:underline">
Allergene &amp; Zusatzstoffe
</Link>
<Link href="/intern/konto" className="hover:underline">
Passkeys
</Link>
{profile.role === "admin" && (
<Link href="/admin" className="hover:underline">
Verwaltung
</Link>
)}
<Link href="/" className="hover:underline">
Zur Speisekarte
</Link>
</nav>
<div className="ml-auto flex items-center gap-3 text-sm">
<span className="text-white/70">
{profile.full_name || user.email}
</span>
<form action="/logout" method="post">
<button type="submit" className="btn btn-del">
Abmelden
</button>
</form>
</div>
</div>
</header>
<main className="max-w-[1600px] mx-auto w-full px-4 py-6 flex-1">
{children}
</main>
</div>
);
}
+11
View File
@@ -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 <WeekEditor monday={mondayOf(woche)} />;
}
+168
View File
@@ -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<CodeItem[]>([]);
const [editing, setEditing] = useState<{
original: string | null; // code vor der Bearbeitung, null = neu
code: string;
name: string;
} | null>(null);
const [error, setError] = useState<string | null>(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 (
<section className="card">
<div className="flex items-center gap-3 mb-3">
<h2 className="text-lg font-bold text-[var(--c-primary)] mr-auto">
{title}
</h2>
<button
type="button"
className="btn btn-add"
onClick={() => setEditing({ original: null, code: "", name: "" })}
>
Hinzufügen
</button>
</div>
{error && (
<p className="mb-2 text-sm text-[var(--c-del)]" role="alert">
{error}
</p>
)}
{editing && (
<form onSubmit={save} className="mb-3 space-y-2 border-b border-gray-200 pb-3">
<div className="max-w-36">
<label className="label">{codeLabel}</label>
<input
className="input"
value={editing.code}
onChange={(e) => setEditing({ ...editing, code: e.target.value })}
required
/>
</div>
<div>
<label className="label">Bezeichnung</label>
<input
className="input"
value={editing.name}
onChange={(e) => setEditing({ ...editing, name: e.target.value })}
required
/>
</div>
<div className="flex gap-2">
<button type="submit" className="btn btn-add">
Speichern
</button>
<button
type="button"
className="btn btn-neutral"
onClick={() => setEditing(null)}
>
Abbrechen
</button>
</div>
</form>
)}
<ul className="divide-y divide-gray-100">
{items.map((item) => (
<li key={item.code} className="flex items-center gap-2 py-1.5 text-sm">
<span className="mr-auto">
<span className="font-semibold">{item.code}</span> – {item.name}
</span>
<button
type="button"
className="btn btn-primary"
onClick={() =>
setEditing({
original: item.code,
code: item.code,
name: item.name,
})
}
>
Bearbeiten
</button>
<button
type="button"
className="btn btn-del"
onClick={() => remove(item)}
>
Löschen
</button>
</li>
))}
{items.length === 0 && (
<li className="py-2 text-gray-500 text-sm">Keine Einträge.</li>
)}
</ul>
</section>
);
}
+178
View File
@@ -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<Dish[]>([]);
const [form, setForm] = useState<DishForm | null>(null);
const [error, setError] = useState<string | null>(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 (
<div className="max-w-3xl">
<div className="flex items-center gap-3 mb-4">
<h1 className="text-xl font-bold text-[var(--c-primary)] mr-auto">
Gerichte
</h1>
<button
type="button"
className="btn btn-add"
onClick={() => setForm(EMPTY_FORM)}
>
Neues Gericht
</button>
</div>
{error && (
<p className="mb-3 text-sm text-[var(--c-del)]" role="alert">
{error}
</p>
)}
{form && (
<form onSubmit={save} className="card mb-4 space-y-3">
<h2 className="font-semibold">
{form.id ? "Gericht bearbeiten" : "Neues Gericht"}
</h2>
<div>
<label className="label" htmlFor="dish-name">
Bezeichnung
</label>
<input
id="dish-name"
className="input"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
required
/>
</div>
<div className="max-w-40">
<label className="label" htmlFor="dish-price">
Preis in €
</label>
<input
id="dish-price"
className="input"
inputMode="decimal"
placeholder="3,50"
value={form.price}
onChange={(e) => setForm({ ...form, price: e.target.value })}
required
/>
</div>
<div className="flex gap-2">
<button type="submit" className="btn btn-add">
Speichern
</button>
<button
type="button"
className="btn btn-neutral"
onClick={() => setForm(null)}
>
Abbrechen
</button>
</div>
</form>
)}
{loading ? (
<p className="text-gray-500">Lade Gerichte…</p>
) : (
<ul className="divide-y divide-gray-200 border border-gray-200 rounded-md bg-white">
{dishes.map((dish) => (
<li key={dish.id} className="flex items-center gap-3 px-4 py-2">
<span className="mr-auto">
<span className="font-semibold">{dish.name}</span>{" "}
<span className="text-gray-500">– {formatPrice(dish.price)}</span>
</span>
<button
type="button"
className="btn btn-primary"
onClick={() =>
setForm({
id: dish.id,
name: dish.name,
price: String(dish.price).replace(".", ","),
})
}
>
Bearbeiten
</button>
<button
type="button"
className="btn btn-del"
onClick={() => remove(dish)}
>
Löschen
</button>
</li>
))}
{dishes.length === 0 && (
<li className="px-4 py-3 text-gray-500">
Noch keine Gerichte angelegt.
</li>
)}
</ul>
)}
</div>
);
}
+116
View File
@@ -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<PasskeyItem[]>([]);
const [error, setError] = useState<string | null>(null);
const [info, setInfo] = useState<string | null>(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 (
<div className="max-w-2xl">
<h1 className="text-xl font-bold text-[var(--c-primary)] mb-2">
Passkeys
</h1>
<p className="text-sm text-gray-600 mb-4">
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.
</p>
{error && (
<p className="mb-3 text-sm text-[var(--c-del)]" role="alert">
{error}
</p>
)}
{info && <p className="mb-3 text-sm text-green-700">{info}</p>}
<button
type="button"
className="btn btn-add mb-4"
onClick={register}
disabled={busy}
>
Neuen Passkey registrieren
</button>
<ul className="divide-y divide-gray-200 border border-gray-200 rounded-md bg-white">
{passkeys.map((pk) => (
<li key={pk.id} className="flex items-center gap-3 px-4 py-2 text-sm">
<span className="mr-auto">
<span className="font-semibold">
{pk.friendly_name || "Passkey"}
</span>{" "}
<span className="text-gray-500">
– angelegt am{" "}
{new Date(pk.created_at).toLocaleDateString("de-DE")}
</span>
</span>
<button
type="button"
className="btn btn-del"
onClick={() => remove(pk)}
>
Löschen
</button>
</li>
))}
{passkeys.length === 0 && (
<li className="px-4 py-3 text-gray-500 text-sm">
Noch keine Passkeys registriert.
</li>
)}
</ul>
</div>
);
}
+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>
);
}