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
+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>
);
}