From de8914f9cc6ef45c05b6809a7fcf0ae648f83d30 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Sun, 19 Jul 2026 23:57:43 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Umschaltbarer=20Hinweismodus=20f=C3=BCr?= =?UTF-8?q?=20Allergene/Zusatzstoffe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 15 +++ app/api/settings/route.ts | 52 ++++++++- app/intern/kennzeichnung/page.tsx | 10 +- app/page.tsx | 91 ++++++++++------ components/DishManager.tsx | 38 ++++--- components/LabelingNoticeEditor.tsx | 142 +++++++++++++++++++++++++ components/PublicMobileMenu.tsx | 6 +- db/migrations/0002_labeling_notice.sql | 11 ++ lib/db/settings.ts | 10 +- lib/i18n/dictionaries.ts | 51 +++++++++ package-lock.json | 4 +- package.json | 2 +- 12 files changed, 376 insertions(+), 56 deletions(-) create mode 100644 components/LabelingNoticeEditor.tsx create mode 100644 db/migrations/0002_labeling_notice.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index b3bf387..3947787 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,21 @@ Alle nennenswerten Änderungen an diesem Projekt werden hier dokumentiert. Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), Versionierung nach [Semantic Versioning](https://semver.org/lang/de/). +## [1.8.0] – 2026-07-19 + +### Hinzugefügt +- Umschaltbarer Hinweismodus für Allergene/Zusatzstoffe: Auf der Seite + „Allergene & Zusatzstoffe“ kann zwischen der bisherigen detaillierten + Auswahl je Gericht und einem einfachen, in Deutsch/Englisch/Polnisch + editierbaren Hinweistext (z. B. „Bei Fragen zu Allergenen und + Zusatzstoffen wenden Sie sich bitte an das Kantinen-Team.“) + umgeschaltet werden. Im Hinweismodus entfällt auf der öffentlichen + Speisekarte die Legende und die Kürzel an den Gerichten, und in der + Gerichte-Verwaltung wird die Allergen-/Zusatzstoff-Auswahl beim + Bearbeiten ausgeblendet (bereits zugeordnete Daten bleiben in der + Datenbank erhalten). Umschalten und Hinweistext bearbeiten dürfen + sowohl Kantinenpersonal als auch Admins. + ## [1.7.0] – 2026-07-19 ### Hinzugefügt diff --git a/app/api/settings/route.ts b/app/api/settings/route.ts index c3ddb9b..685e14a 100644 --- a/app/api/settings/route.ts +++ b/app/api/settings/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; import { query } from "@/lib/db"; -import { getSettings } from "@/lib/db/settings"; +import { getSettings, type LabelingMode } from "@/lib/db/settings"; import { authErrorResponse, requireRole } from "@/lib/auth/session"; import { isSameOrigin } from "@/lib/origin"; @@ -8,14 +8,58 @@ export async function GET() { return NextResponse.json(await getSettings()); } +interface SettingsPatchBody { + colors?: Record; + labeling_mode?: LabelingMode; + labeling_notice?: string; + labeling_notice_en?: string | null; + labeling_notice_pl?: string | null; +} + export async function PATCH(request: Request) { if (!isSameOrigin(request)) { return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 }); } try { - await requireRole(["admin"]); - const { colors } = (await request.json()) as { colors: Record }; - await query("update settings set colors = $1 where id = 1", [JSON.stringify(colors)]); + // Farben bleiben admin-only wie bisher; die Allergen-/Zusatzstoff- + // Anzeige darf auch Kantinenpersonal umschalten/bearbeiten. + const session = await requireRole(["staff", "admin"]); + const body = (await request.json()) as SettingsPatchBody; + + const sets: string[] = []; + const params: unknown[] = []; + + if (body.colors !== undefined) { + if (session.user.role !== "admin") { + return NextResponse.json({ error: "forbidden" }, { status: 403 }); + } + params.push(JSON.stringify(body.colors)); + sets.push(`colors = $${params.length}`); + } + if (body.labeling_mode !== undefined) { + if (body.labeling_mode !== "detailed" && body.labeling_mode !== "notice") { + return NextResponse.json({ error: "invalid_input" }, { status: 400 }); + } + params.push(body.labeling_mode); + sets.push(`labeling_mode = $${params.length}`); + } + if (body.labeling_notice !== undefined) { + params.push(body.labeling_notice); + sets.push(`labeling_notice = $${params.length}`); + } + if (body.labeling_notice_en !== undefined) { + params.push(body.labeling_notice_en); + sets.push(`labeling_notice_en = $${params.length}`); + } + if (body.labeling_notice_pl !== undefined) { + params.push(body.labeling_notice_pl); + sets.push(`labeling_notice_pl = $${params.length}`); + } + + if (sets.length === 0) { + return NextResponse.json({ error: "invalid_input" }, { status: 400 }); + } + await query(`update settings set ${sets.join(", ")} where id = 1`, params); return NextResponse.json({ ok: true }); } catch (err) { const res = authErrorResponse(err); diff --git a/app/intern/kennzeichnung/page.tsx b/app/intern/kennzeichnung/page.tsx index dcfbaed..f1965a9 100644 --- a/app/intern/kennzeichnung/page.tsx +++ b/app/intern/kennzeichnung/page.tsx @@ -1,11 +1,15 @@ import CodeListManager from "@/components/CodeListManager"; +import LabelingNoticeEditor from "@/components/LabelingNoticeEditor"; export default function KennzeichnungPage() { const translateEnabled = !!process.env.DEEPL_API_KEY; return ( -
- - +
+ +
+ + +
); } diff --git a/app/page.tsx b/app/page.tsx index afd70d9..ddbde7a 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -42,6 +42,7 @@ export default async function Home({ ]); const logo = logoUrl(settings?.logo_path); + const labelingDetailed = (settings?.labeling_mode ?? "detailed") !== "notice"; return (
@@ -99,7 +100,12 @@ export default async function Home({ {/* Mobil: Tag (Standard) oder Woche umschaltbar */} - + {/* Ab "sm": klassische Wochentabelle */}
@@ -148,7 +154,9 @@ export default async function Home({ {localized(e.dish, locale)} - {codes && {codes}} + {labelingDetailed && codes && ( + {codes} + )} {" – "} {formatPrice(e.dish.price)}
@@ -163,35 +171,56 @@ export default async function Home({
- {/* Allergene & Zusatzstoffe: Legende zu den Kürzeln bei den Gerichten */} -
-
-

- {dict.publicMenu.allergens} -

-
    - {allergens.map((a) => ( -
  • - {a.code} –{" "} - {localized(a, locale)} -
  • - ))} -
-
-
-

- {dict.publicMenu.additives} -

-
    - {additives.map((a) => ( -
  • - {a.code} –{" "} - {localized(a, locale)} -
  • - ))} -
-
-
+ {/* Allergene & Zusatzstoffe: entweder Legende zu den Kürzeln bei den + Gerichten, oder – falls im Hinweismodus – nur ein Hinweistext. */} + {labelingDetailed ? ( +
+
+

+ {dict.publicMenu.allergens} +

+
    + {allergens.map((a) => ( +
  • + {a.code} –{" "} + {localized(a, locale)} +
  • + ))} +
+
+
+

+ {dict.publicMenu.additives} +

+
    + {additives.map((a) => ( +
  • + {a.code} –{" "} + {localized(a, locale)} +
  • + ))} +
+
+
+ ) : ( + settings && ( +
+

+ {dict.publicMenu.labelingNoticeTitle} +

+

+ {localized( + { + name: settings.labeling_notice, + name_en: settings.labeling_notice_en, + name_pl: settings.labeling_notice_pl, + }, + locale + )} +

+
+ ) + )} ); } diff --git a/components/DishManager.tsx b/components/DishManager.tsx index 901ba72..6a42e0b 100644 --- a/components/DishManager.tsx +++ b/components/DishManager.tsx @@ -46,6 +46,7 @@ export default function DishManager({ const [selected, setSelected] = useState>(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({ )} - - + {labelingDetailed ? ( + <> + + + + ) : ( +

{dict.dishManager.labelingDisabledHint}

+ )}