Allergene/Zusatzstoffe je Gericht auswählbar, klein angezeigt

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.
This commit is contained in:
2026-07-18 00:46:39 +02:00
parent 1db89a54bf
commit 675503e1f4
4 changed files with 202 additions and 52 deletions
+28
View File
@@ -108,3 +108,31 @@ body {
color: #4b5563; color: #4b5563;
margin-left: 0.15rem; 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;
}
+42
View File
@@ -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 (
<div>
<label className="label">{label}</label>
{items.length === 0 ? (
<p className="text-sm text-gray-400">Keine Einträge vorhanden.</p>
) : (
<div className="flex flex-wrap gap-1.5">
{items.map((item) => {
const active = selected.includes(item.code);
return (
<button
key={item.code}
type="button"
onClick={() => onToggle(item.code)}
className={`chip ${active ? "chip-active" : ""}`}
aria-pressed={active}
title={item.name}
>
<span className="font-semibold">{item.code}</span> {item.name}
</button>
);
})}
</div>
)}
</div>
);
}
+107 -45
View File
@@ -2,20 +2,31 @@
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { createClient } from "@/lib/supabase/client"; import { createClient } from "@/lib/supabase/client";
import { formatPrice } from "@/lib/menu"; import { dishCodes, formatPrice } from "@/lib/menu";
import type { Dish } from "@/lib/types"; import type { CodeItem, Dish } from "@/lib/types";
import ChipPicker from "@/components/ChipPicker";
interface DishForm { interface DishForm {
id: string | null; // null = neues Gericht id: string | null; // null = neues Gericht
name: string; name: string;
price: 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() { export default function DishManager() {
const supabase = useMemo(() => createClient(), []); const supabase = useMemo(() => createClient(), []);
const [dishes, setDishes] = useState<Dish[]>([]); const [dishes, setDishes] = useState<Dish[]>([]);
const [allergens, setAllergens] = useState<CodeItem[]>([]);
const [additives, setAdditives] = useState<CodeItem[]>([]);
const [form, setForm] = useState<DishForm | null>(null); const [form, setForm] = useState<DishForm | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -30,12 +41,18 @@ export default function DishManager() {
}, [dishes, search]); }, [dishes, search]);
const load = useCallback(async () => { const load = useCallback(async () => {
const { data, error } = await supabase const [dishesRes, allergensRes, additivesRes] = await Promise.all([
.from("dishes") supabase.from("dishes").select("*").order("name"),
.select("*") supabase.from("allergens").select("*").order("sort"),
.order("name"); supabase.from("additives").select("*").order("sort"),
if (error) setError(`Laden fehlgeschlagen: ${error.message}`); ]);
else setDishes(data as Dish[]); 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); setLoading(false);
}, [supabase]); }, [supabase]);
@@ -52,7 +69,12 @@ export default function DishManager() {
setError("Bitte einen gültigen Preis angeben, z. B. 3,50"); setError("Bitte einen gültigen Preis angeben, z. B. 3,50");
return; 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 const { error } = form.id
? await supabase.from("dishes").update(payload).eq("id", form.id) ? await supabase.from("dishes").update(payload).eq("id", form.id)
: await supabase.from("dishes").insert(payload); : await supabase.from("dishes").insert(payload);
@@ -122,6 +144,26 @@ export default function DishManager() {
await load(); 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 = const allSelected =
visible.length > 0 && visible.every((d) => selected.has(d.id)); visible.length > 0 && visible.every((d) => selected.has(d.id));
@@ -147,7 +189,7 @@ export default function DishManager() {
)} )}
{form && ( {form && (
<form onSubmit={save} className="card mb-4 space-y-3"> <form onSubmit={save} className="card mb-4 space-y-4">
<h2 className="font-semibold"> <h2 className="font-semibold">
{form.id ? "Gericht bearbeiten" : "Neues Gericht"} {form.id ? "Gericht bearbeiten" : "Neues Gericht"}
</h2> </h2>
@@ -177,6 +219,18 @@ export default function DishManager() {
required required
/> />
</div> </div>
<ChipPicker
label="Allergene"
items={allergens}
selected={form.allergens}
onToggle={toggleAllergen}
/>
<ChipPicker
label="Zusatzstoffe"
items={additives}
selected={form.additives}
onToggle={toggleAdditive}
/>
<div className="flex gap-2"> <div className="flex gap-2">
<button type="submit" className="btn btn-add"> <button type="submit" className="btn btn-add">
Speichern Speichern
@@ -230,40 +284,48 @@ export default function DishManager() {
</div> </div>
)} )}
<ul className="divide-y divide-gray-200 border border-gray-200 rounded-md bg-white"> <ul className="divide-y divide-gray-200 border border-gray-200 rounded-md bg-white">
{visible.map((dish) => ( {visible.map((dish) => {
<li key={dish.id} className="flex items-center gap-3 px-4 py-2"> const codes = dishCodes(dish);
<input return (
type="checkbox" <li key={dish.id} className="flex items-center gap-3 px-4 py-2">
checked={selected.has(dish.id)} <input
onChange={() => toggleOne(dish.id)} type="checkbox"
aria-label={`${dish.name} auswählen`} checked={selected.has(dish.id)}
/> onChange={() => toggleOne(dish.id)}
<span className="mr-auto"> aria-label={`${dish.name} auswählen`}
<span className="font-semibold">{dish.name}</span>{" "} />
<span className="text-gray-500">– {formatPrice(dish.price)}</span> <span className="mr-auto">
</span> <span className="font-semibold">{dish.name}</span>
<button {codes && <sup className="dish-codes">{codes}</sup>}{" "}
type="button" <span className="text-gray-500">
className="btn btn-primary" – {formatPrice(dish.price)}
onClick={() => </span>
setForm({ </span>
id: dish.id, <button
name: dish.name, type="button"
price: String(dish.price).replace(".", ","), className="btn btn-primary"
}) onClick={() =>
} setForm({
> id: dish.id,
Bearbeiten name: dish.name,
</button> price: String(dish.price).replace(".", ","),
<button allergens: dish.allergens ?? [],
type="button" additives: dish.additives ?? [],
className="btn btn-del" })
onClick={() => remove(dish)} }
> >
Löschen Bearbeiten
</button> </button>
</li> <button
))} type="button"
className="btn btn-del"
onClick={() => remove(dish)}
>
Löschen
</button>
</li>
);
})}
{dishes.length === 0 && ( {dishes.length === 0 && (
<li className="px-4 py-3 text-gray-500"> <li className="px-4 py-3 text-gray-500">
Noch keine Gerichte angelegt. Noch keine Gerichte angelegt.
+25 -7
View File
@@ -3,7 +3,16 @@
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { createClient } from "@/lib/supabase/client"; 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"; import type { Dish, MenuEntry, Section } from "@/lib/types";
export default function WeekEditor({ monday }: { monday: string }) { 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("dishes").select("*").order("name"),
supabase supabase
.from("menu_entries") .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) .gte("menu_date", monday)
.lte("menu_date", friday) .lte("menu_date", friday)
.order("position") .order("position")
@@ -79,9 +90,6 @@ export default function WeekEditor({ monday }: { monday: string }) {
await load(); await load();
} }
const cell = (iso: string, sectionKey: string) =>
entries.filter((e) => e.menu_date === iso && e.section_key === sectionKey);
return ( return (
<div> <div>
<div className="flex flex-wrap items-center gap-3 mb-4"> <div className="flex flex-wrap items-center gap-3 mb-4">
@@ -91,6 +99,12 @@ export default function WeekEditor({ monday }: { monday: string }) {
<Link href={`/intern?woche=${addDays(monday, -7)}`} className="btn btn-add"> <Link href={`/intern?woche=${addDays(monday, -7)}`} className="btn btn-add">
← Vorherige Woche ← Vorherige Woche
</Link> </Link>
<Link
href={`/intern?woche=${mondayOf(todayISO())}`}
className="btn btn-primary"
>
Heute
</Link>
<Link href={`/intern?woche=${addDays(monday, 7)}`} className="btn btn-add"> <Link href={`/intern?woche=${addDays(monday, 7)}`} className="btn btn-add">
Nächste Woche → Nächste Woche →
</Link> </Link>
@@ -129,9 +143,12 @@ export default function WeekEditor({ monday }: { monday: string }) {
adding?.iso === d.iso && adding?.section === s.key; adding?.iso === d.iso && adding?.section === s.key;
return ( return (
<td key={d.iso} className="cell"> <td key={d.iso} className="cell">
{cell(d.iso, s.key).map((e) => ( {entriesFor(entries, d.iso, s.key).map((e) => {
const codes = dishCodes(e.dish);
return (
<div key={e.id} className="dish"> <div key={e.id} className="dish">
<span className="font-bold">{e.dish.name}</span> <span className="font-bold">{e.dish.name}</span>
{codes && <sup className="dish-codes">{codes}</sup>}
{" – "} {" – "}
{formatPrice(e.dish.price)} {formatPrice(e.dish.price)}
<div className="mt-1"> <div className="mt-1">
@@ -144,7 +161,8 @@ export default function WeekEditor({ monday }: { monday: string }) {
</button> </button>
</div> </div>
</div> </div>
))} );
})}
{isAdding ? ( {isAdding ? (
<div className="mt-2 space-y-1 text-left"> <div className="mt-2 space-y-1 text-left">
<select <select