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:
@@ -0,0 +1,5 @@
|
|||||||
|
import SectionManager from "@/components/SectionManager";
|
||||||
|
|
||||||
|
export default function BereichePage() {
|
||||||
|
return <SectionManager />;
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ export default function AppHeader({
|
|||||||
|
|
||||||
const menuLinks = [
|
const menuLinks = [
|
||||||
{ href: "/intern", label: dict.header.weekPlan },
|
{ href: "/intern", label: dict.header.weekPlan },
|
||||||
|
{ href: "/intern/bereiche", label: dict.header.sections },
|
||||||
{ href: "/intern/gerichte", label: dict.header.dishes },
|
{ href: "/intern/gerichte", label: dict.header.dishes },
|
||||||
{ href: "/intern/kennzeichnung", label: dict.header.labels },
|
{ href: "/intern/kennzeichnung", label: dict.header.labels },
|
||||||
{ href: "/intern/vorschlaege", label: dict.header.suggestions },
|
{ href: "/intern/vorschlaege", label: dict.header.suggestions },
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -30,6 +30,7 @@ const de = {
|
|||||||
canteen: "Kantine",
|
canteen: "Kantine",
|
||||||
menuGroup: "Speiseplan",
|
menuGroup: "Speiseplan",
|
||||||
weekPlan: "Wochenplan",
|
weekPlan: "Wochenplan",
|
||||||
|
sections: "Bereiche",
|
||||||
dishes: "Gerichte",
|
dishes: "Gerichte",
|
||||||
labels: "Allergene & Zusatzstoffe",
|
labels: "Allergene & Zusatzstoffe",
|
||||||
adminGroup: "Verwaltung",
|
adminGroup: "Verwaltung",
|
||||||
@@ -120,6 +121,24 @@ const de = {
|
|||||||
saveError: "Speichern fehlgeschlagen: {message}",
|
saveError: "Speichern fehlgeschlagen: {message}",
|
||||||
deleteError: "Löschen fehlgeschlagen: {message}",
|
deleteError: "Löschen fehlgeschlagen: {message}",
|
||||||
},
|
},
|
||||||
|
sectionManager: {
|
||||||
|
title: "Bereiche",
|
||||||
|
newSection: "Neuer Bereich",
|
||||||
|
editSection: "Bereich bearbeiten",
|
||||||
|
nameLabel: "Bezeichnung",
|
||||||
|
nameEnLabel: "Bezeichnung (Englisch)",
|
||||||
|
namePlLabel: "Bezeichnung (Polnisch)",
|
||||||
|
timeFrom: "Von",
|
||||||
|
timeTo: "Bis",
|
||||||
|
moveUp: "Nach oben",
|
||||||
|
moveDown: "Nach unten",
|
||||||
|
empty: "Noch keine Bereiche angelegt.",
|
||||||
|
confirmDelete:
|
||||||
|
"„{name}“ wirklich löschen? Zugewiesene Gerichte werden auch aus allen Wochenplänen entfernt.",
|
||||||
|
loadError: "Laden fehlgeschlagen: {message}",
|
||||||
|
saveError: "Speichern fehlgeschlagen: {message}",
|
||||||
|
deleteError: "Löschen fehlgeschlagen: {message}",
|
||||||
|
},
|
||||||
userManager: {
|
userManager: {
|
||||||
title: "Benutzer",
|
title: "Benutzer",
|
||||||
newUser: "Neuer Benutzer",
|
newUser: "Neuer Benutzer",
|
||||||
@@ -269,6 +288,7 @@ const en: Dict = {
|
|||||||
canteen: "Canteen",
|
canteen: "Canteen",
|
||||||
menuGroup: "Menu",
|
menuGroup: "Menu",
|
||||||
weekPlan: "Weekly plan",
|
weekPlan: "Weekly plan",
|
||||||
|
sections: "Sections",
|
||||||
dishes: "Dishes",
|
dishes: "Dishes",
|
||||||
labels: "Allergens & Additives",
|
labels: "Allergens & Additives",
|
||||||
adminGroup: "Administration",
|
adminGroup: "Administration",
|
||||||
@@ -359,6 +379,24 @@ const en: Dict = {
|
|||||||
saveError: "Saving failed: {message}",
|
saveError: "Saving failed: {message}",
|
||||||
deleteError: "Deleting failed: {message}",
|
deleteError: "Deleting failed: {message}",
|
||||||
},
|
},
|
||||||
|
sectionManager: {
|
||||||
|
title: "Sections",
|
||||||
|
newSection: "New section",
|
||||||
|
editSection: "Edit section",
|
||||||
|
nameLabel: "Name",
|
||||||
|
nameEnLabel: "Name (English)",
|
||||||
|
namePlLabel: "Name (Polish)",
|
||||||
|
timeFrom: "From",
|
||||||
|
timeTo: "To",
|
||||||
|
moveUp: "Move up",
|
||||||
|
moveDown: "Move down",
|
||||||
|
empty: "No sections yet.",
|
||||||
|
confirmDelete:
|
||||||
|
"Really delete “{name}”? Assigned dishes will also be removed from all weekly plans.",
|
||||||
|
loadError: "Loading failed: {message}",
|
||||||
|
saveError: "Saving failed: {message}",
|
||||||
|
deleteError: "Deleting failed: {message}",
|
||||||
|
},
|
||||||
userManager: {
|
userManager: {
|
||||||
title: "Users",
|
title: "Users",
|
||||||
newUser: "New user",
|
newUser: "New user",
|
||||||
@@ -506,6 +544,7 @@ const pl: Dict = {
|
|||||||
canteen: "Stołówka",
|
canteen: "Stołówka",
|
||||||
menuGroup: "Jadłospis",
|
menuGroup: "Jadłospis",
|
||||||
weekPlan: "Plan tygodnia",
|
weekPlan: "Plan tygodnia",
|
||||||
|
sections: "Sekcje",
|
||||||
dishes: "Dania",
|
dishes: "Dania",
|
||||||
labels: "Alergeny i dodatki",
|
labels: "Alergeny i dodatki",
|
||||||
adminGroup: "Administracja",
|
adminGroup: "Administracja",
|
||||||
@@ -596,6 +635,24 @@ const pl: Dict = {
|
|||||||
saveError: "Zapisywanie nie powiodło się: {message}",
|
saveError: "Zapisywanie nie powiodło się: {message}",
|
||||||
deleteError: "Usuwanie nie powiodło się: {message}",
|
deleteError: "Usuwanie nie powiodło się: {message}",
|
||||||
},
|
},
|
||||||
|
sectionManager: {
|
||||||
|
title: "Sekcje",
|
||||||
|
newSection: "Nowa sekcja",
|
||||||
|
editSection: "Edytuj sekcję",
|
||||||
|
nameLabel: "Nazwa",
|
||||||
|
nameEnLabel: "Nazwa (angielski)",
|
||||||
|
namePlLabel: "Nazwa (polski)",
|
||||||
|
timeFrom: "Od",
|
||||||
|
timeTo: "Do",
|
||||||
|
moveUp: "Przesuń w górę",
|
||||||
|
moveDown: "Przesuń w dół",
|
||||||
|
empty: "Nie dodano jeszcze żadnych sekcji.",
|
||||||
|
confirmDelete:
|
||||||
|
"Czy na pewno usunąć „{name}”? Przypisane dania zostaną również usunięte ze wszystkich planów tygodniowych.",
|
||||||
|
loadError: "Wczytywanie nie powiodło się: {message}",
|
||||||
|
saveError: "Zapisywanie nie powiodło się: {message}",
|
||||||
|
deleteError: "Usuwanie nie powiodło się: {message}",
|
||||||
|
},
|
||||||
userManager: {
|
userManager: {
|
||||||
title: "Użytkownicy",
|
title: "Użytkownicy",
|
||||||
newUser: "Nowy użytkownik",
|
newUser: "Nowy użytkownik",
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
-- =============================================================
|
||||||
|
-- Bereiche (Brotzeit/Mittagessen/…) vollständig verwaltbar machen:
|
||||||
|
-- anlegen, umbenennen, Uhrzeit ändern, löschen.
|
||||||
|
-- =============================================================
|
||||||
|
|
||||||
|
-- Neue Bereiche bekommen einen automatisch generierten Schlüssel
|
||||||
|
-- (wie bei dishes.id) statt eines von Hand vergebenen Slugs.
|
||||||
|
alter table public.sections
|
||||||
|
alter column key set default (gen_random_uuid())::text;
|
||||||
|
|
||||||
|
-- Löscht man einen Bereich, werden seine Wochenplan-Einträge mit
|
||||||
|
-- entfernt (analog zu menu_entries_dish_id_fkey bei Gerichten) statt
|
||||||
|
-- die Löschung zu blockieren.
|
||||||
|
alter table public.menu_entries
|
||||||
|
drop constraint menu_entries_section_key_fkey;
|
||||||
|
alter table public.menu_entries
|
||||||
|
add constraint menu_entries_section_key_fkey
|
||||||
|
foreign key (section_key) references public.sections(key) on delete cascade;
|
||||||
|
|
||||||
|
-- Bisher gab es nur eine Lesepolicy für sections; jetzt dürfen
|
||||||
|
-- Kantinenmitarbeiter/Admins Bereiche auch anlegen/ändern/löschen.
|
||||||
|
create policy "sections_staff_write" on public.sections
|
||||||
|
for all to authenticated
|
||||||
|
using (public.app_role() in ('staff', 'admin'))
|
||||||
|
with check (public.app_role() in ('staff', 'admin'));
|
||||||
Reference in New Issue
Block a user