From 868a5d2fcfe9e0dd52591dc29c0e49caad11af48 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Sat, 18 Jul 2026 08:14:32 +0200 Subject: [PATCH] =?UTF-8?q?Bereiche=20vollst=C3=A4ndig=20verwaltbar=20(anl?= =?UTF-8?q?egen/bearbeiten/l=C3=B6schen/Uhrzeit)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- app/intern/bereiche/page.tsx | 5 + components/AppHeader.tsx | 1 + components/SectionManager.tsx | 298 ++++++++++++++++++ lib/i18n/dictionaries.ts | 57 ++++ .../migrations/0006_variable_sections.sql | 25 ++ 5 files changed, 386 insertions(+) create mode 100644 app/intern/bereiche/page.tsx create mode 100644 components/SectionManager.tsx create mode 100644 supabase/migrations/0006_variable_sections.sql diff --git a/app/intern/bereiche/page.tsx b/app/intern/bereiche/page.tsx new file mode 100644 index 0000000..57a346d --- /dev/null +++ b/app/intern/bereiche/page.tsx @@ -0,0 +1,5 @@ +import SectionManager from "@/components/SectionManager"; + +export default function BereichePage() { + return ; +} diff --git a/components/AppHeader.tsx b/components/AppHeader.tsx index 4a651d1..2c89e6f 100644 --- a/components/AppHeader.tsx +++ b/components/AppHeader.tsx @@ -23,6 +23,7 @@ export default function AppHeader({ const menuLinks = [ { href: "/intern", label: dict.header.weekPlan }, + { href: "/intern/bereiche", label: dict.header.sections }, { href: "/intern/gerichte", label: dict.header.dishes }, { href: "/intern/kennzeichnung", label: dict.header.labels }, { href: "/intern/vorschlaege", label: dict.header.suggestions }, diff --git a/components/SectionManager.tsx b/components/SectionManager.tsx new file mode 100644 index 0000000..f537be0 --- /dev/null +++ b/components/SectionManager.tsx @@ -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([]); + const [form, setForm] = useState(null); + const [error, setError] = useState(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 ( +
+
+

+ {dict.sectionManager.title} +

+ +
+ + {error && ( +

+ {error} +

+ )} + + {form && ( +
+

+ {form.key ? dict.sectionManager.editSection : dict.sectionManager.newSection} +

+
+ + setForm({ ...form, label: e.target.value })} + required + /> +
+
+
+ + setForm({ ...form, labelEn: e.target.value })} + /> +
+
+ + setForm({ ...form, labelPl: e.target.value })} + /> +
+
+
+
+ + setForm({ ...form, timeFrom: e.target.value })} + required + /> +
+
+ + setForm({ ...form, timeTo: e.target.value })} + required + /> +
+
+
+ + +
+
+ )} + + {loading ? ( +

{dict.common.loading}

+ ) : ( +
    + {sections.map((section, idx) => ( +
  • +
    + + +
    + + {localized(section, locale)}{" "} + – {section.time_range} + + + +
  • + ))} + {sections.length === 0 && ( +
  • {dict.sectionManager.empty}
  • + )} +
+ )} +
+ ); +} diff --git a/lib/i18n/dictionaries.ts b/lib/i18n/dictionaries.ts index de5254e..71c32f8 100644 --- a/lib/i18n/dictionaries.ts +++ b/lib/i18n/dictionaries.ts @@ -30,6 +30,7 @@ const de = { canteen: "Kantine", menuGroup: "Speiseplan", weekPlan: "Wochenplan", + sections: "Bereiche", dishes: "Gerichte", labels: "Allergene & Zusatzstoffe", adminGroup: "Verwaltung", @@ -120,6 +121,24 @@ const de = { saveError: "Speichern 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: { title: "Benutzer", newUser: "Neuer Benutzer", @@ -269,6 +288,7 @@ const en: Dict = { canteen: "Canteen", menuGroup: "Menu", weekPlan: "Weekly plan", + sections: "Sections", dishes: "Dishes", labels: "Allergens & Additives", adminGroup: "Administration", @@ -359,6 +379,24 @@ const en: Dict = { saveError: "Saving 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: { title: "Users", newUser: "New user", @@ -506,6 +544,7 @@ const pl: Dict = { canteen: "Stołówka", menuGroup: "Jadłospis", weekPlan: "Plan tygodnia", + sections: "Sekcje", dishes: "Dania", labels: "Alergeny i dodatki", adminGroup: "Administracja", @@ -596,6 +635,24 @@ const pl: Dict = { saveError: "Zapisywanie 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: { title: "Użytkownicy", newUser: "Nowy użytkownik", diff --git a/supabase/migrations/0006_variable_sections.sql b/supabase/migrations/0006_variable_sections.sql new file mode 100644 index 0000000..629a50f --- /dev/null +++ b/supabase/migrations/0006_variable_sections.sql @@ -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'));