From 675503e1f49881c0f4eefd962e4d4cb1d4d8f3bc Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Sat, 18 Jul 2026 00:46:39 +0200 Subject: [PATCH] =?UTF-8?q?Allergene/Zusatzstoffe=20je=20Gericht=20ausw?= =?UTF-8?q?=C3=A4hlbar,=20klein=20angezeigt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chip-Auswahl im Gericht-Formular (Klick zum An-/Abwählen) statt Checkbox-Liste; Kürzel erscheinen hochgestellt neben dem Gerichtnamen in Speisekarte, mobiler Ansicht und Wochenplan-Editor. Editor bekommt außerdem einen Heute-Button. --- app/globals.css | 28 +++++++ components/ChipPicker.tsx | 42 ++++++++++ components/DishManager.tsx | 152 ++++++++++++++++++++++++++----------- components/WeekEditor.tsx | 32 ++++++-- 4 files changed, 202 insertions(+), 52 deletions(-) create mode 100644 components/ChipPicker.tsx diff --git a/app/globals.css b/app/globals.css index 4139480..f378cda 100644 --- a/app/globals.css +++ b/app/globals.css @@ -108,3 +108,31 @@ body { color: #4b5563; margin-left: 0.15rem; } + +/* ---------- Chip-Auswahl (Allergene/Zusatzstoffe je Gericht) ---------- */ +.chip { + display: inline-flex; + align-items: center; + gap: 0.25rem; + border-radius: 9999px; + padding: 0.2rem 0.65rem; + font-size: 0.75rem; + line-height: 1.2; + border: 1px solid #d1d5db; + background: #f9fafb; + color: #374151; + cursor: pointer; + transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease; +} +.chip:hover { border-color: var(--c-primary); } +.chip-active { + background: var(--c-primary); + border-color: var(--c-primary); + color: #ffffff; +} +.dish-codes { + font-size: 0.65rem; + color: #6b7280; + vertical-align: super; + margin-left: 0.15rem; +} diff --git a/components/ChipPicker.tsx b/components/ChipPicker.tsx new file mode 100644 index 0000000..8241854 --- /dev/null +++ b/components/ChipPicker.tsx @@ -0,0 +1,42 @@ +"use client"; + +import type { CodeItem } from "@/lib/types"; + +export default function ChipPicker({ + items, + selected, + onToggle, + label, +}: { + items: CodeItem[]; + selected: string[]; + onToggle: (code: string) => void; + label: string; +}) { + return ( +
+ + {items.length === 0 ? ( +

Keine Einträge vorhanden.

+ ) : ( +
+ {items.map((item) => { + const active = selected.includes(item.code); + return ( + + ); + })} +
+ )} +
+ ); +} diff --git a/components/DishManager.tsx b/components/DishManager.tsx index 8af6ec9..1851f3c 100644 --- a/components/DishManager.tsx +++ b/components/DishManager.tsx @@ -2,20 +2,31 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { createClient } from "@/lib/supabase/client"; -import { formatPrice } from "@/lib/menu"; -import type { Dish } from "@/lib/types"; +import { dishCodes, formatPrice } from "@/lib/menu"; +import type { CodeItem, Dish } from "@/lib/types"; +import ChipPicker from "@/components/ChipPicker"; interface DishForm { id: string | null; // null = neues Gericht name: string; price: string; + allergens: string[]; + additives: string[]; } -const EMPTY_FORM: DishForm = { id: null, name: "", price: "" }; +const EMPTY_FORM: DishForm = { + id: null, + name: "", + price: "", + allergens: [], + additives: [], +}; export default function DishManager() { const supabase = useMemo(() => createClient(), []); const [dishes, setDishes] = useState([]); + const [allergens, setAllergens] = useState([]); + const [additives, setAdditives] = useState([]); const [form, setForm] = useState(null); const [error, setError] = useState(null); const [loading, setLoading] = useState(true); @@ -30,12 +41,18 @@ export default function DishManager() { }, [dishes, search]); const load = useCallback(async () => { - const { data, error } = await supabase - .from("dishes") - .select("*") - .order("name"); - if (error) setError(`Laden fehlgeschlagen: ${error.message}`); - else setDishes(data as Dish[]); + const [dishesRes, allergensRes, additivesRes] = await Promise.all([ + supabase.from("dishes").select("*").order("name"), + supabase.from("allergens").select("*").order("sort"), + supabase.from("additives").select("*").order("sort"), + ]); + const problem = dishesRes.error ?? allergensRes.error ?? additivesRes.error; + if (problem) setError(`Laden fehlgeschlagen: ${problem.message}`); + else { + setDishes(dishesRes.data as Dish[]); + setAllergens(allergensRes.data as CodeItem[]); + setAdditives(additivesRes.data as CodeItem[]); + } setLoading(false); }, [supabase]); @@ -52,7 +69,12 @@ export default function DishManager() { setError("Bitte einen gültigen Preis angeben, z. B. 3,50"); return; } - const payload = { name: form.name.trim(), price }; + const payload = { + name: form.name.trim(), + price, + allergens: form.allergens, + additives: form.additives, + }; const { error } = form.id ? await supabase.from("dishes").update(payload).eq("id", form.id) : await supabase.from("dishes").insert(payload); @@ -122,6 +144,26 @@ export default function DishManager() { 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], + }); + } + const allSelected = visible.length > 0 && visible.every((d) => selected.has(d.id)); @@ -147,7 +189,7 @@ export default function DishManager() { )} {form && ( -
+

{form.id ? "Gericht bearbeiten" : "Neues Gericht"}

@@ -177,6 +219,18 @@ export default function DishManager() { required /> + +
)}
    - {visible.map((dish) => ( -
  • - toggleOne(dish.id)} - aria-label={`${dish.name} auswählen`} - /> - - {dish.name}{" "} - – {formatPrice(dish.price)} - - - -
  • - ))} + {visible.map((dish) => { + const codes = dishCodes(dish); + return ( +
  • + toggleOne(dish.id)} + aria-label={`${dish.name} auswählen`} + /> + + {dish.name} + {codes && {codes}}{" "} + + – {formatPrice(dish.price)} + + + + +
  • + ); + })} {dishes.length === 0 && (
  • Noch keine Gerichte angelegt. diff --git a/components/WeekEditor.tsx b/components/WeekEditor.tsx index 267a055..1ca491e 100644 --- a/components/WeekEditor.tsx +++ b/components/WeekEditor.tsx @@ -3,7 +3,16 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import Link from "next/link"; import { createClient } from "@/lib/supabase/client"; -import { addDays, formatDateLong, formatPrice, WEEKDAYS } from "@/lib/menu"; +import { + addDays, + dishCodes, + entriesFor, + formatDateLong, + formatPrice, + mondayOf, + todayISO, + WEEKDAYS, +} from "@/lib/menu"; import type { Dish, MenuEntry, Section } from "@/lib/types"; export default function WeekEditor({ monday }: { monday: string }) { @@ -32,7 +41,9 @@ export default function WeekEditor({ monday }: { monday: string }) { supabase.from("dishes").select("*").order("name"), supabase .from("menu_entries") - .select("id, menu_date, section_key, position, dish:dishes(id, name, price)") + .select( + "id, menu_date, section_key, position, dish:dishes(id, name, price, allergens, additives)" + ) .gte("menu_date", monday) .lte("menu_date", friday) .order("position") @@ -79,9 +90,6 @@ export default function WeekEditor({ monday }: { monday: string }) { await load(); } - const cell = (iso: string, sectionKey: string) => - entries.filter((e) => e.menu_date === iso && e.section_key === sectionKey); - return (
    @@ -91,6 +99,12 @@ export default function WeekEditor({ monday }: { monday: string }) { ← Vorherige Woche + + Heute + Nächste Woche → @@ -129,9 +143,12 @@ export default function WeekEditor({ monday }: { monday: string }) { adding?.iso === d.iso && adding?.section === s.key; return ( - {cell(d.iso, s.key).map((e) => ( + {entriesFor(entries, d.iso, s.key).map((e) => { + const codes = dishCodes(e.dish); + return (
    {e.dish.name} + {codes && {codes}} {" – "} {formatPrice(e.dish.price)}
    @@ -144,7 +161,8 @@ export default function WeekEditor({ monday }: { monday: string }) {
    - ))} + ); + })} {isAdding ? (