Files
Speisekarte/components/CodeListManager.tsx
T
florian.wilde a9a9cf7ba4 v1.5: Alle Manager-Komponenten auf eigene API-Routen umgestellt
Neue interne Route Handler unter app/api/ (dishes inkl. Bulk-Delete,
sections, allergens, additives, menu-entries, dish-suggestions,
settings, admin/branding) ersetzen den direkten Browser→Postgrest-
Zugriff der bisherigen Supabase-Anbindung. Jede Route prüft Autorisierung
selbst (lib/auth/session.ts requireRole) statt sich auf Postgres-RLS zu
verlassen – ersetzt 1:1 die bisherigen Policies (öffentlich lesbar /
staff+admin schreiben / nur admin für Einstellungen & Benutzer).

Rezeptvorschlag-Dublettencheck (SuggestionForm) läuft jetzt zwingend
serverseitig im POST-Handler, da es keinen Browser→Postgres-Pfad mehr
gibt. "Passkey annehmen" (SuggestionReview) läuft transaktional
(Gericht anlegen + Vorschlag löschen in einem Schritt).

Logo/Favicon: neuer lokaler Dateispeicher (lib/branding.ts, Docker-
Volume) statt Supabase-Storage-Bucket, ausgeliefert über
app/branding/[filename]. logoUrl()/faviconUrl() liefern jetzt einfache
relative Pfade statt einer fest verdrahteten Supabase-Storage-URL.

DeepL-Route (api/translate) und Benutzerverwaltung (api/admin/users)
laufen jetzt gegen die users-Tabelle statt Supabase Auth.

Alle "use client"-Manager-Komponenten (DishManager, WeekEditor,
SectionManager, CodeListManager, SuggestionForm/Review, UserManager,
DesignEditor) rufen ihre jeweiligen /api/-Routen per fetch() statt den
Supabase-Browser-Client zu nutzen.
2026-07-18 11:01:52 +02:00

263 lines
8.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
// Pflege der globalen Allergen-/Zusatzstofflisten, die unterhalb
// der öffentlichen Speisekarte angezeigt werden.
import { useCallback, useEffect, useState } from "react";
import { formatMessage } from "@/lib/i18n/shared";
import { localized } from "@/lib/types";
import type { CodeItem } from "@/lib/types";
import { useDict, useLocale } from "@/components/i18n/LocaleProvider";
export default function CodeListManager({
table,
translateEnabled,
}: {
table: "allergens" | "additives";
translateEnabled: boolean;
}) {
const dict = useDict();
const locale = useLocale();
const title =
table === "allergens" ? dict.codeList.allergensTitle : dict.codeList.additivesTitle;
const codeLabel =
table === "allergens"
? dict.codeList.allergenCodeLabel
: dict.codeList.additiveCodeLabel;
const [items, setItems] = useState<CodeItem[]>([]);
const [editing, setEditing] = useState<{
original: string | null; // code vor der Bearbeitung, null = neu
code: string;
name: string;
nameEn: string;
namePl: string;
} | null>(null);
const [error, setError] = useState<string | null>(null);
const [translateState, setTranslateState] = useState<"idle" | "busy" | "done">(
"idle"
);
const load = useCallback(async () => {
const res = await fetch(`/api/${table}`);
if (!res.ok) setError(formatMessage(dict.codeList.loadError, { message: String(res.status) }));
else setItems(await res.json());
}, [table, dict]);
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;
const payload = {
code,
name,
name_en: editing.nameEn.trim() || null,
name_pl: editing.namePl.trim() || null,
};
const res = editing.original
? await fetch(`/api/${table}/${editing.original}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
})
: await fetch(`/api/${table}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setError(
data.error === "duplicate_code"
? formatMessage(dict.codeList.duplicateCode, { code })
: formatMessage(dict.codeList.saveError, { message: data.error ?? String(res.status) })
);
return;
}
setEditing(null);
await load();
}
async function remove(item: CodeItem) {
if (
!window.confirm(
formatMessage(dict.codeList.confirmDelete, { code: item.code, name: item.name })
)
)
return;
const res = await fetch(`/api/${table}/${item.code}`, { method: "DELETE" });
if (!res.ok)
setError(formatMessage(dict.codeList.deleteError, { message: String(res.status) }));
await load();
}
async function autoTranslate() {
if (!editing || !editing.name.trim()) return;
setTranslateState("busy");
setError(null);
try {
const res = await fetch("/api/translate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
table,
id: editing.original,
text: editing.name.trim(),
}),
});
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? `Fehler (${res.status})`);
setEditing((prev) =>
prev ? { ...prev, nameEn: data.en ?? prev.nameEn, namePl: data.pl ?? prev.namePl } : prev
);
setTranslateState("done");
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
setTranslateState("idle");
}
}
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: "", nameEn: "", namePl: "" });
setTranslateState("idle");
}}
>
{dict.common.add}
</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">{dict.codeList.nameLabel}</label>
<input
className="input"
value={editing.name}
onChange={(e) => setEditing({ ...editing, name: e.target.value })}
required
/>
</div>
<div className="grid gap-2 sm:grid-cols-2">
<div>
<label className="label">{dict.dishManager.nameEnLabel}</label>
<input
className="input"
value={editing.nameEn}
onChange={(e) => setEditing({ ...editing, nameEn: e.target.value })}
/>
</div>
<div>
<label className="label">{dict.dishManager.namePlLabel}</label>
<input
className="input"
value={editing.namePl}
onChange={(e) => setEditing({ ...editing, namePl: e.target.value })}
/>
</div>
</div>
{translateEnabled && (
<div>
<button
type="button"
className="btn btn-neutral"
onClick={autoTranslate}
disabled={translateState === "busy" || !editing.name.trim()}
>
{translateState === "busy"
? dict.dishManager.translating
: dict.dishManager.autoTranslate}
</button>
{translateState === "done" && (
<span className="ml-2 text-sm text-green-700">
{dict.dishManager.translated}
</span>
)}
</div>
)}
<div className="flex gap-2">
<button type="submit" className="btn btn-add">
{dict.common.save}
</button>
<button
type="button"
className="btn btn-neutral"
onClick={() => setEditing(null)}
>
{dict.common.cancel}
</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> –{" "}
{localized(item, locale)}
</span>
<button
type="button"
className="btn btn-primary"
onClick={() => {
setEditing({
original: item.code,
code: item.code,
name: item.name,
nameEn: item.name_en ?? "",
namePl: item.name_pl ?? "",
});
setTranslateState("idle");
}}
>
{dict.common.edit}
</button>
<button
type="button"
className="btn btn-del"
onClick={() => remove(item)}
>
{dict.common.delete}
</button>
</li>
))}
{items.length === 0 && (
<li className="py-2 text-gray-500 text-sm">{dict.codeList.empty}</li>
)}
</ul>
</section>
);
}