Bereiche vollständig verwaltbar (anlegen/bearbeiten/löschen/Uhrzeit)

Migration 0006: sections.key bekommt einen automatisch generierten
Schlüssel (wie dishes.id), menu_entries.section_key kaskadiert beim
Löschen eines Bereichs, neue RLS-Policy erlaubt Kantinenmitarbeitern/
Admins Schreibzugriff auf sections.

Neue Seite „Bereiche" im Kantinen-Bereich (SectionManager): Bereiche
anlegen, umbenennen (inkl. EN/PL), Uhrzeit setzen, löschen und per
Pfeil-Buttons neu anordnen.
This commit is contained in:
2026-07-18 08:14:32 +02:00
parent 8b98294998
commit 868a5d2fcf
5 changed files with 386 additions and 0 deletions
+298
View File
@@ -0,0 +1,298 @@
"use client";
// Verwaltung der Speiseplan-Bereiche (Brotzeit, Mittagessen, …):
// anlegen, umbenennen, Uhrzeit ändern, löschen, Reihenfolge anpassen.
import { useCallback, useEffect, useMemo, useState } from "react";
import { createClient } from "@/lib/supabase/client";
import { formatMessage } from "@/lib/i18n/shared";
import { localized } from "@/lib/types";
import type { Section } from "@/lib/types";
import { useDict, useLocale } from "@/components/i18n/LocaleProvider";
interface SectionForm {
key: string | null; // null = neuer Bereich
label: string;
labelEn: string;
labelPl: string;
timeFrom: string;
timeTo: string;
}
const EMPTY_FORM: SectionForm = {
key: null,
label: "",
labelEn: "",
labelPl: "",
timeFrom: "",
timeTo: "",
};
function splitTimeRange(range: string): { from: string; to: string } {
const [from, to] = range.split("-").map((s) => s.trim());
return { from: from ?? "", to: to ?? "" };
}
export default function SectionManager() {
const supabase = useMemo(() => createClient(), []);
const dict = useDict();
const locale = useLocale();
const [sections, setSections] = useState<Section[]>([]);
const [form, setForm] = useState<SectionForm | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const load = useCallback(async () => {
const { data, error } = await supabase.from("sections").select("*").order("sort");
if (error)
setError(formatMessage(dict.sectionManager.loadError, { message: error.message }));
else setSections(data as Section[]);
setLoading(false);
}, [supabase, dict]);
useEffect(() => {
load();
}, [load]);
async function save(e: React.FormEvent) {
e.preventDefault();
if (!form) return;
setError(null);
const label = form.label.trim();
if (!label || !form.timeFrom || !form.timeTo) return;
const payload = {
label,
label_en: form.labelEn.trim() || null,
label_pl: form.labelPl.trim() || null,
time_range: `${form.timeFrom} - ${form.timeTo}`,
};
let dbError;
if (form.key) {
({ error: dbError } = await supabase
.from("sections")
.update(payload)
.eq("key", form.key));
} else {
// Frisch aus der DB lesen statt den lokalen State zu nutzen, damit
// ein veralteter Snapshot nicht zu doppelten sort-Werten führt.
const { data: maxRow } = await supabase
.from("sections")
.select("sort")
.order("sort", { ascending: false })
.limit(1)
.maybeSingle();
({ error: dbError } = await supabase
.from("sections")
.insert({ ...payload, sort: (maxRow?.sort ?? 0) + 1 }));
}
if (dbError) {
setError(formatMessage(dict.sectionManager.saveError, { message: dbError.message }));
return;
}
setForm(null);
await load();
}
async function remove(section: Section) {
if (
!window.confirm(
formatMessage(dict.sectionManager.confirmDelete, { name: section.label })
)
)
return;
const { error } = await supabase.from("sections").delete().eq("key", section.key);
if (error)
setError(formatMessage(dict.sectionManager.deleteError, { message: error.message }));
await load();
}
async function move(section: Section, direction: -1 | 1) {
const idx = sections.findIndex((s) => s.key === section.key);
const neighbor = sections[idx + direction];
if (!neighbor) return;
setBusy(true);
setError(null);
const [r1, r2] = await Promise.all([
supabase.from("sections").update({ sort: neighbor.sort }).eq("key", section.key),
supabase.from("sections").update({ sort: section.sort }).eq("key", neighbor.key),
]);
const dbError = r1.error ?? r2.error;
if (dbError)
setError(formatMessage(dict.sectionManager.saveError, { message: dbError.message }));
setBusy(false);
await load();
}
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">
{dict.sectionManager.title}
</h1>
<button
type="button"
className="btn btn-add"
onClick={() => setForm(EMPTY_FORM)}
>
{dict.sectionManager.newSection}
</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.key ? dict.sectionManager.editSection : dict.sectionManager.newSection}
</h2>
<div>
<label className="label" htmlFor="section-label">
{dict.sectionManager.nameLabel}
</label>
<input
id="section-label"
className="input"
value={form.label}
onChange={(e) => setForm({ ...form, label: e.target.value })}
required
/>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div>
<label className="label" htmlFor="section-label-en">
{dict.sectionManager.nameEnLabel}
</label>
<input
id="section-label-en"
className="input"
value={form.labelEn}
onChange={(e) => setForm({ ...form, labelEn: e.target.value })}
/>
</div>
<div>
<label className="label" htmlFor="section-label-pl">
{dict.sectionManager.namePlLabel}
</label>
<input
id="section-label-pl"
className="input"
value={form.labelPl}
onChange={(e) => setForm({ ...form, labelPl: e.target.value })}
/>
</div>
</div>
<div className="flex gap-3">
<div className="max-w-32">
<label className="label" htmlFor="section-time-from">
{dict.sectionManager.timeFrom}
</label>
<input
id="section-time-from"
type="time"
className="input"
value={form.timeFrom}
onChange={(e) => setForm({ ...form, timeFrom: e.target.value })}
required
/>
</div>
<div className="max-w-32">
<label className="label" htmlFor="section-time-to">
{dict.sectionManager.timeTo}
</label>
<input
id="section-time-to"
type="time"
className="input"
value={form.timeTo}
onChange={(e) => setForm({ ...form, timeTo: e.target.value })}
required
/>
</div>
</div>
<div className="flex gap-2">
<button type="submit" className="btn btn-add">
{dict.common.save}
</button>
<button
type="button"
className="btn btn-neutral"
onClick={() => setForm(null)}
>
{dict.common.cancel}
</button>
</div>
</form>
)}
{loading ? (
<p className="text-gray-500">{dict.common.loading}</p>
) : (
<ul className="divide-y divide-gray-200 border border-gray-200 rounded-md bg-white">
{sections.map((section, idx) => (
<li key={section.key} className="flex items-center gap-3 px-4 py-2">
<div className="flex flex-col">
<button
type="button"
className="text-gray-400 hover:text-[var(--c-primary)] disabled:opacity-30"
onClick={() => move(section, -1)}
disabled={busy || idx === 0}
aria-label={dict.sectionManager.moveUp}
title={dict.sectionManager.moveUp}
>
▲
</button>
<button
type="button"
className="text-gray-400 hover:text-[var(--c-primary)] disabled:opacity-30"
onClick={() => move(section, 1)}
disabled={busy || idx === sections.length - 1}
aria-label={dict.sectionManager.moveDown}
title={dict.sectionManager.moveDown}
>
▼
</button>
</div>
<span className="mr-auto">
<span className="font-semibold">{localized(section, locale)}</span>{" "}
<span className="text-gray-500">– {section.time_range}</span>
</span>
<button
type="button"
className="btn btn-primary"
onClick={() => {
const { from, to } = splitTimeRange(section.time_range);
setForm({
key: section.key,
label: section.label,
labelEn: section.label_en ?? "",
labelPl: section.label_pl ?? "",
timeFrom: from,
timeTo: to,
});
}}
>
{dict.common.edit}
</button>
<button
type="button"
className="btn btn-del"
onClick={() => remove(section)}
>
{dict.common.delete}
</button>
</li>
))}
{sections.length === 0 && (
<li className="px-4 py-3 text-gray-500">{dict.sectionManager.empty}</li>
)}
</ul>
)}
</div>
);
}