Files
Speisekarte/components/DishManager.tsx
T
florian.wildeandClaude Sonnet 5 de8914f9cc feat: Umschaltbarer Hinweismodus für Allergene/Zusatzstoffe
Neue Migration 0002_labeling_notice.sql: settings.labeling_mode
("detailed"/"notice") + labeling_notice(_en/_pl). Auf der Seite
"Allergene & Zusatzstoffe" (LabelingNoticeEditor) kann zwischen der
detaillierten Auswahl je Gericht und einem editierbaren Hinweistext
umgeschaltet werden. Im Hinweismodus entfällt auf der öffentlichen
Speisekarte (Desktop + Mobile) die Legende und die Kürzel an den
Gerichten, in der Gerichte-Verwaltung wird die Chip-Auswahl beim
Bearbeiten ausgeblendet. Umschalten/Bearbeiten ist für staff und admin
freigegeben, Farben in /api/settings bleiben admin-only.

Version 1.8.0.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 23:57:43 +02:00

533 lines
17 KiB
TypeScript
Raw Permalink 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";
import { useCallback, useEffect, useMemo, useState } from "react";
import { dishCodes, formatPrice } from "@/lib/menu";
import { formatMessage } from "@/lib/i18n/shared";
import { localized } from "@/lib/types";
import type { CodeItem, Dish } from "@/lib/types";
import ChipPicker from "@/components/ChipPicker";
import { useDict, useLocale } from "@/components/i18n/LocaleProvider";
interface DishForm {
id: string | null; // null = neues Gericht
name: string;
nameEn: string;
namePl: string;
price: string;
allergens: string[];
additives: string[];
}
const EMPTY_FORM: DishForm = {
id: null,
name: "",
nameEn: "",
namePl: "",
price: "",
allergens: [],
additives: [],
};
export default function DishManager({
translateEnabled,
}: {
translateEnabled: boolean;
}) {
const dict = useDict();
const locale = useLocale();
const [dishes, setDishes] = useState<Dish[]>([]);
const [allergens, setAllergens] = useState<CodeItem[]>([]);
const [additives, setAdditives] = useState<CodeItem[]>([]);
const [form, setForm] = useState<DishForm | null>(null);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [ioBusy, setIoBusy] = useState(false);
const [loading, setLoading] = useState(true);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [bulkBusy, setBulkBusy] = useState(false);
const [search, setSearch] = useState("");
const [labelingDetailed, setLabelingDetailed] = useState(true);
const [translateState, setTranslateState] = useState<"idle" | "busy" | "done">(
"idle"
);
// Live-Suche: filtert die Liste bereits während des Tippens
const visible = useMemo(() => {
const q = search.trim().toLowerCase();
return q ? dishes.filter((d) => d.name.toLowerCase().includes(q)) : dishes;
}, [dishes, search]);
const load = useCallback(async () => {
const [dishesRes, allergensRes, additivesRes, settingsRes] = await Promise.all([
fetch("/api/dishes"),
fetch("/api/allergens"),
fetch("/api/additives"),
fetch("/api/settings"),
]);
if (!dishesRes.ok || !allergensRes.ok || !additivesRes.ok) {
setError(formatMessage(dict.dishManager.loadError, { message: "load failed" }));
} else {
setDishes(await dishesRes.json());
setAllergens(await allergensRes.json());
setAdditives(await additivesRes.json());
}
if (settingsRes.ok) {
const settings = await settingsRes.json();
setLabelingDetailed((settings?.labeling_mode ?? "detailed") !== "notice");
}
setLoading(false);
}, [dict]);
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(dict.dishManager.invalidPrice);
return;
}
const payload = {
name: form.name.trim(),
name_en: form.nameEn.trim() || null,
name_pl: form.namePl.trim() || null,
price,
allergens: form.allergens,
additives: form.additives,
};
const res = form.id
? await fetch(`/api/dishes/${form.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
})
: await fetch("/api/dishes", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) {
setError(formatMessage(dict.dishManager.saveError, { message: String(res.status) }));
return;
}
setForm(null);
await load();
}
async function remove(dish: Dish) {
if (
!window.confirm(
formatMessage(dict.dishManager.confirmDelete, { name: dish.name })
)
)
return;
const res = await fetch(`/api/dishes/${dish.id}`, { method: "DELETE" });
if (!res.ok)
setError(formatMessage(dict.dishManager.deleteError, { message: String(res.status) }));
setSelected((prev) => {
const next = new Set(prev);
next.delete(dish.id);
return next;
});
await load();
}
function toggleOne(id: string) {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
// "Alle auswählen" bezieht sich auf die aktuell gefilterte Liste
function toggleAll() {
setSelected((prev) => {
const next = new Set(prev);
const everyVisibleSelected = visible.every((d) => next.has(d.id));
visible.forEach((d) =>
everyVisibleSelected ? next.delete(d.id) : next.add(d.id)
);
return next;
});
}
async function removeSelected() {
if (selected.size === 0) return;
if (
!window.confirm(
formatMessage(dict.dishManager.confirmDeleteSelected, {
count: selected.size,
})
)
)
return;
setBulkBusy(true);
setError(null);
const res = await fetch("/api/dishes/bulk-delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ids: Array.from(selected) }),
});
if (!res.ok)
setError(formatMessage(dict.dishManager.deleteError, { message: String(res.status) }));
setSelected(new Set());
setBulkBusy(false);
await load();
}
function toggleAllergen(code: string) {
if (!form) return;
setForm({
...form,
allergens: form.allergens.includes(code)
? form.allergens.filter((c) => c !== code)
: [...form.allergens, code],
});
}
function toggleAdditive(code: string) {
if (!form) return;
setForm({
...form,
additives: form.additives.includes(code)
? form.additives.filter((c) => c !== code)
: [...form.additives, code],
});
}
async function autoTranslate() {
if (!form || !form.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: "dishes",
id: form.id,
text: form.name.trim(),
}),
});
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? `Fehler (${res.status})`);
setForm((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");
}
}
async function exportDishes() {
setError(null);
setNotice(null);
const res = await fetch("/api/dishes/export");
if (!res.ok) {
setError(formatMessage(dict.dishManager.exportError, { message: String(res.status) }));
return;
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "gerichte.csv";
a.click();
URL.revokeObjectURL(url);
}
async function importDishes(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
e.target.value = "";
if (!file) return;
setError(null);
setNotice(null);
setIoBusy(true);
const fd = new FormData();
fd.append("file", file);
const res = await fetch("/api/dishes/import", { method: "POST", body: fd });
setIoBusy(false);
if (!res.ok) {
setError(formatMessage(dict.dishManager.importError, { message: String(res.status) }));
return;
}
const data = (await res.json()) as {
created: number;
updated: number;
skipped: { row: number; reason: string }[];
};
let msg = formatMessage(dict.dishManager.importSuccess, {
created: data.created,
updated: data.updated,
});
if (data.skipped.length > 0) {
const reasons = data.skipped
.map((s) => formatMessage(dict.dishManager.importSkippedRow, s))
.join("; ");
msg +=
" " + formatMessage(dict.dishManager.importSkipped, { count: data.skipped.length, reasons });
}
setNotice(msg);
await load();
}
const allSelected =
visible.length > 0 && visible.every((d) => selected.has(d.id));
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">
{dict.dishManager.title}
</h1>
<button type="button" className="btn btn-neutral" onClick={exportDishes}>
{dict.dishManager.exportButton}
</button>
<label className="btn btn-neutral cursor-pointer">
{dict.dishManager.importButton}
<input
type="file"
accept=".csv,text/csv"
className="hidden"
onChange={importDishes}
disabled={ioBusy}
/>
</label>
<button
type="button"
className="btn btn-add"
onClick={() => {
setForm(EMPTY_FORM);
setTranslateState("idle");
}}
>
{dict.dishManager.newDish}
</button>
</div>
{error && (
<p className="mb-3 text-sm text-[var(--c-del)]" role="alert">
{error}
</p>
)}
{notice && <p className="mb-3 text-sm text-green-700">{notice}</p>}
{form && (
<form onSubmit={save} className="card mb-4 space-y-4">
<h2 className="font-semibold">
{form.id ? dict.dishManager.editDish : dict.dishManager.newDish}
</h2>
<div>
<label className="label" htmlFor="dish-name">
{dict.dishManager.nameLabel}
</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">
{dict.dishManager.priceLabel}
</label>
<input
id="dish-price"
className="input"
inputMode="decimal"
placeholder={dict.dishManager.pricePlaceholder}
value={form.price}
onChange={(e) => setForm({ ...form, price: e.target.value })}
required
/>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div>
<label className="label" htmlFor="dish-name-en">
{dict.dishManager.nameEnLabel}
</label>
<input
id="dish-name-en"
className="input"
value={form.nameEn}
onChange={(e) => setForm({ ...form, nameEn: e.target.value })}
/>
</div>
<div>
<label className="label" htmlFor="dish-name-pl">
{dict.dishManager.namePlLabel}
</label>
<input
id="dish-name-pl"
className="input"
value={form.namePl}
onChange={(e) => setForm({ ...form, namePl: e.target.value })}
/>
</div>
</div>
{translateEnabled && (
<div>
<button
type="button"
className="btn btn-neutral"
onClick={autoTranslate}
disabled={translateState === "busy" || !form.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>
)}
{labelingDetailed ? (
<>
<ChipPicker
label={dict.codeList.allergensTitle}
items={allergens}
selected={form.allergens}
onToggle={toggleAllergen}
/>
<ChipPicker
label={dict.codeList.additivesTitle}
items={additives}
selected={form.additives}
onToggle={toggleAdditive}
/>
</>
) : (
<p className="text-sm text-gray-500">{dict.dishManager.labelingDisabledHint}</p>
)}
<div className="flex gap-2">
<button type="submit" className="btn btn-add">
{dict.common.save}
</button>
<button
type="button"
className="btn btn-neutral"
onClick={() => setForm(null)}
>
{dict.common.cancel}
</button>
</div>
</form>
)}
{loading ? (
<p className="text-gray-500">{dict.common.loading}</p>
) : (
<>
{dishes.length > 0 && (
<input
type="search"
className="input mb-3"
placeholder={dict.dishManager.searchPlaceholder}
value={search}
onChange={(e) => setSearch(e.target.value)}
aria-label={dict.dishManager.searchPlaceholder}
/>
)}
{dishes.length > 0 && (
<div className="flex items-center gap-3 mb-2 text-sm">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={allSelected}
onChange={toggleAll}
/>
{dict.dishManager.selectAll}
</label>
<span className="text-gray-500">
{formatMessage(dict.dishManager.selectedCount, { count: selected.size })}
</span>
<button
type="button"
className="btn btn-del ml-auto"
onClick={removeSelected}
disabled={selected.size === 0 || bulkBusy}
>
{selected.size > 0
? formatMessage(dict.dishManager.deleteSelectedCount, {
count: selected.size,
})
: dict.dishManager.deleteSelected}
</button>
</div>
)}
<ul className="divide-y divide-gray-200 border border-gray-200 rounded-md bg-white">
{visible.map((dish) => {
const codes = dishCodes(dish);
return (
<li key={dish.id} className="flex items-center gap-3 px-4 py-2">
<input
type="checkbox"
checked={selected.has(dish.id)}
onChange={() => toggleOne(dish.id)}
aria-label={dish.name}
/>
<span className="mr-auto">
<span className="font-semibold">{localized(dish, locale)}</span>
{codes && <sup className="dish-codes">{codes}</sup>}{" "}
<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,
nameEn: dish.name_en ?? "",
namePl: dish.name_pl ?? "",
price: String(dish.price).replace(".", ","),
allergens: dish.allergens ?? [],
additives: dish.additives ?? [],
});
setTranslateState("idle");
}}
>
{dict.common.edit}
</button>
<button
type="button"
className="btn btn-del"
onClick={() => remove(dish)}
>
{dict.common.delete}
</button>
</li>
);
})}
{dishes.length === 0 && (
<li className="px-4 py-3 text-gray-500">{dict.dishManager.empty}</li>
)}
{dishes.length > 0 && visible.length === 0 && (
<li className="px-4 py-3 text-gray-500">
{formatMessage(dict.dishManager.noResults, { query: search })}
</li>
)}
</ul>
</>
)}
</div>
);
}