Kantinen-Bereich: Wochenplan-Editor, Gerichte, Kennzeichnungen, Passkey-Verwaltung
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user