Files
Speisekarte/components/DishManager.tsx
T
florian.wilde 675503e1f4 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.
2026-07-18 00:46:39 +02:00

345 lines
10 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { createClient } from "@/lib/supabase/client";
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: "",
allergens: [],
additives: [],
};
export default function DishManager() {
const supabase = useMemo(() => createClient(), []);
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 [loading, setLoading] = useState(true);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [bulkBusy, setBulkBusy] = useState(false);
const [search, setSearch] = useState("");
// 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] = 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]);
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("Bitte einen gültigen Preis angeben, z. B. 3,50");
return;
}
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);
if (error) {
setError(`Speichern fehlgeschlagen: ${error.message}`);
return;
}
setForm(null);
await load();
}
async function remove(dish: Dish) {
if (
!window.confirm(
`„${dish.name}“ wirklich löschen? Das Gericht wird auch aus allen Wochenplänen entfernt.`
)
)
return;
const { error } = await supabase.from("dishes").delete().eq("id", dish.id);
if (error) setError(`Löschen fehlgeschlagen: ${error.message}`);
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(
`${selected.size} Gericht(e) wirklich löschen? Sie werden auch aus allen Wochenplänen entfernt.`
)
)
return;
setBulkBusy(true);
setError(null);
const { error } = await supabase
.from("dishes")
.delete()
.in("id", Array.from(selected));
if (error) setError(`Löschen fehlgeschlagen: ${error.message}`);
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],
});
}
const allSelected =
visible.length > 0 && visible.every((d) => selected.has(d.id));
return (
<div className="max-w-3xl">
<div className="flex items-center gap-3 mb-4">
<h1 className="text-xl font-bold text-[var(--c-primary)] mr-auto">
Gerichte
</h1>
<button
type="button"
className="btn btn-add"
onClick={() => setForm(EMPTY_FORM)}
>
Neues Gericht
</button>
</div>
{error && (
<p className="mb-3 text-sm text-[var(--c-del)]" role="alert">
{error}
</p>
)}
{form && (
<form onSubmit={save} className="card mb-4 space-y-4">
<h2 className="font-semibold">
{form.id ? "Gericht bearbeiten" : "Neues Gericht"}
</h2>
<div>
<label className="label" htmlFor="dish-name">
Bezeichnung
</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">
Preis in €
</label>
<input
id="dish-price"
className="input"
inputMode="decimal"
placeholder="3,50"
value={form.price}
onChange={(e) => setForm({ ...form, price: e.target.value })}
required
/>
</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">
<button type="submit" className="btn btn-add">
Speichern
</button>
<button
type="button"
className="btn btn-neutral"
onClick={() => setForm(null)}
>
Abbrechen
</button>
</div>
</form>
)}
{loading ? (
<p className="text-gray-500">Lade Gerichte…</p>
) : (
<>
{dishes.length > 0 && (
<input
type="search"
className="input mb-3"
placeholder="Gericht suchen…"
value={search}
onChange={(e) => setSearch(e.target.value)}
aria-label="Gerichte durchsuchen"
/>
)}
{dishes.length > 0 && (
<div className="flex items-center gap-3 mb-2 text-sm">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={allSelected}
onChange={toggleAll}
/>
Alle auswählen
</label>
<span className="text-gray-500">
{selected.size} ausgewählt
</span>
<button
type="button"
className="btn btn-del ml-auto"
onClick={removeSelected}
disabled={selected.size === 0 || bulkBusy}
>
Ausgewählte löschen{selected.size > 0 ? ` (${selected.size})` : ""}
</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 items-center gap-3 px-4 py-2">
<input
type="checkbox"
checked={selected.has(dish.id)}
onChange={() => toggleOne(dish.id)}
aria-label={`${dish.name} auswählen`}
/>
<span className="mr-auto">
<span className="font-semibold">{dish.name}</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,
price: String(dish.price).replace(".", ","),
allergens: dish.allergens ?? [],
additives: dish.additives ?? [],
})
}
>
Bearbeiten
</button>
<button
type="button"
className="btn btn-del"
onClick={() => remove(dish)}
>
Löschen
</button>
</li>
);
})}
{dishes.length === 0 && (
<li className="px-4 py-3 text-gray-500">
Noch keine Gerichte angelegt.
</li>
)}
{dishes.length > 0 && visible.length === 0 && (
<li className="px-4 py-3 text-gray-500">
Keine Treffer für „{search}“.
</li>
)}
</ul>
</>
)}
</div>
);
}