Files
Speisekarte/components/DishManager.tsx
T
florian.wilde cd8b0fab9f feat: Speiseplan-Inhalte variabel mehrsprachig (Teil 2)
Gerichtnamen, Essenszeiten und Allergen-/Zusatzstoff-Bezeichnungen sind
jetzt genauso dynamisch mehrsprachig wie die Bedienoberfläche aus
v1.10.0, statt fest auf Englisch/Polnisch begrenzt zu sein. Neue
Übersetzungstabellen ersetzen die alten name_en/name_pl-Spalten,
DeepL-Auto-Übersetzung und CSV-Import/-Export unterstützen jetzt
beliebig viele aktive Sprachen.
2026-07-20 11:10:06 +02:00

530 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, useLanguages, useLocale } from "@/components/i18n/LocaleProvider";
interface DishForm {
id: string | null; // null = neues Gericht
name: string;
translations: Record<string, string>;
price: string;
allergens: string[];
additives: string[];
}
const EMPTY_FORM: DishForm = {
id: null,
name: "",
translations: {},
price: "",
allergens: [],
additives: [],
};
export default function DishManager({
translateEnabled,
}: {
translateEnabled: boolean;
}) {
const dict = useDict();
const locale = useLocale();
const languages = useLanguages().filter((l) => l.code !== "de");
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(),
price,
allergens: form.allergens,
additives: form.additives,
translations: form.translations,
};
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, translations: { ...prev.translations, ...(data.translations ?? {}) } }
: 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 flex-wrap 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>
{languages.length > 0 && (
<div className="grid gap-3 sm:grid-cols-2">
{languages.map((lang) => (
<div key={lang.code}>
<label className="label" htmlFor={`dish-name-${lang.code}`}>
{lang.label}
</label>
<input
id={`dish-name-${lang.code}`}
className="input"
value={form.translations[lang.code] ?? ""}
onChange={(e) =>
setForm({
...form,
translations: { ...form.translations, [lang.code]: e.target.value },
})
}
/>
</div>
))}
</div>
)}
{translateEnabled && languages.length > 0 && (
<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 flex-wrap 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 flex-wrap 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,
translations: { ...dish.translations },
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>
);
}