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>
This commit is contained in:
+25
-13
@@ -46,6 +46,7 @@ export default function DishManager({
|
||||
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"
|
||||
);
|
||||
@@ -57,10 +58,11 @@ export default function DishManager({
|
||||
}, [dishes, search]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const [dishesRes, allergensRes, additivesRes] = await Promise.all([
|
||||
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" }));
|
||||
@@ -69,6 +71,10 @@ export default function DishManager({
|
||||
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]);
|
||||
|
||||
@@ -390,18 +396,24 @@ export default function DishManager({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ChipPicker
|
||||
label={dict.codeList.allergensTitle}
|
||||
items={allergens}
|
||||
selected={form.allergens}
|
||||
onToggle={toggleAllergen}
|
||||
/>
|
||||
<ChipPicker
|
||||
label={dict.codeList.additivesTitle}
|
||||
items={additives}
|
||||
selected={form.additives}
|
||||
onToggle={toggleAdditive}
|
||||
/>
|
||||
{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}
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
"use client";
|
||||
|
||||
// Umschaltung zwischen der detaillierten Allergen-/Zusatzstoff-Auswahl
|
||||
// je Gericht und einem einfachen, editierbaren Hinweistext.
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { formatMessage } from "@/lib/i18n/shared";
|
||||
import { useDict } from "@/components/i18n/LocaleProvider";
|
||||
import type { LabelingMode } from "@/lib/db/settings";
|
||||
|
||||
export default function LabelingNoticeEditor() {
|
||||
const dict = useDict();
|
||||
const [mode, setMode] = useState<LabelingMode>("detailed");
|
||||
const [noticeDe, setNoticeDe] = useState("");
|
||||
const [noticeEn, setNoticeEn] = useState("");
|
||||
const [noticePl, setNoticePl] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [info, setInfo] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const res = await fetch("/api/settings");
|
||||
if (!res.ok) {
|
||||
setError(formatMessage(dict.labelingNotice.loadError, { message: String(res.status) }));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
setMode(data.labeling_mode ?? "detailed");
|
||||
setNoticeDe(data.labeling_notice ?? "");
|
||||
setNoticeEn(data.labeling_notice_en ?? "");
|
||||
setNoticePl(data.labeling_notice_pl ?? "");
|
||||
setLoading(false);
|
||||
}, [dict]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
async function save() {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
setInfo(null);
|
||||
const res = await fetch("/api/settings", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
labeling_mode: mode,
|
||||
labeling_notice: noticeDe.trim(),
|
||||
labeling_notice_en: noticeEn.trim() || null,
|
||||
labeling_notice_pl: noticePl.trim() || null,
|
||||
}),
|
||||
});
|
||||
setBusy(false);
|
||||
if (!res.ok) {
|
||||
setError(formatMessage(dict.labelingNotice.saveError, { message: String(res.status) }));
|
||||
return;
|
||||
}
|
||||
setInfo(dict.labelingNotice.saved);
|
||||
}
|
||||
|
||||
if (loading) return null;
|
||||
|
||||
return (
|
||||
<section className="card">
|
||||
<h2 className="font-semibold mb-2">{dict.labelingNotice.title}</h2>
|
||||
<p className="text-sm text-gray-600 mb-3">{dict.labelingNotice.hint}</p>
|
||||
|
||||
{error && (
|
||||
<p className="mb-3 text-sm text-[var(--c-del)]" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{info && <p className="mb-3 text-sm text-green-700">{info}</p>}
|
||||
|
||||
<div className="flex flex-col gap-2 mb-4">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="labeling-mode"
|
||||
checked={mode === "detailed"}
|
||||
onChange={() => setMode("detailed")}
|
||||
/>
|
||||
{dict.labelingNotice.modeDetailed}
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="labeling-mode"
|
||||
checked={mode === "notice"}
|
||||
onChange={() => setMode("notice")}
|
||||
/>
|
||||
{dict.labelingNotice.modeNotice}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 mb-4">
|
||||
<div>
|
||||
<label className="label" htmlFor="labeling-notice-de">
|
||||
{dict.labelingNotice.noticeDeLabel}
|
||||
</label>
|
||||
<textarea
|
||||
id="labeling-notice-de"
|
||||
className="input"
|
||||
rows={2}
|
||||
value={noticeDe}
|
||||
onChange={(e) => setNoticeDe(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label" htmlFor="labeling-notice-en">
|
||||
{dict.labelingNotice.noticeEnLabel}
|
||||
</label>
|
||||
<textarea
|
||||
id="labeling-notice-en"
|
||||
className="input"
|
||||
rows={2}
|
||||
value={noticeEn}
|
||||
onChange={(e) => setNoticeEn(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label" htmlFor="labeling-notice-pl">
|
||||
{dict.labelingNotice.noticePlLabel}
|
||||
</label>
|
||||
<textarea
|
||||
id="labeling-notice-pl"
|
||||
className="input"
|
||||
rows={2}
|
||||
value={noticePl}
|
||||
onChange={(e) => setNoticePl(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" className="btn btn-add" onClick={save} disabled={busy}>
|
||||
{dict.labelingNotice.save}
|
||||
</button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -27,10 +27,12 @@ export default function PublicMobileMenu({
|
||||
monday,
|
||||
sections,
|
||||
entries,
|
||||
showLabeling,
|
||||
}: {
|
||||
monday: string;
|
||||
sections: Section[];
|
||||
entries: MenuEntry[];
|
||||
showLabeling: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const dict = useDict();
|
||||
@@ -86,7 +88,9 @@ export default function PublicMobileMenu({
|
||||
<span className="font-semibold">
|
||||
{localized(e.dish, locale)}
|
||||
</span>
|
||||
{codes && <sup className="dish-codes">{codes}</sup>}{" "}
|
||||
{showLabeling && codes && (
|
||||
<sup className="dish-codes">{codes}</sup>
|
||||
)}{" "}
|
||||
– {formatPrice(e.dish.price)}
|
||||
</p>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user