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.
530 lines
17 KiB
TypeScript
530 lines
17 KiB
TypeScript
"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>
|
||
);
|
||
}
|