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.
345 lines
10 KiB
TypeScript
345 lines
10 KiB
TypeScript
"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>
|
||
);
|
||
}
|