Merge feature/v1.4: Essenszeiten variabel, DeepL-Button-Sichtbarkeit, Bugfix mobiler Sprachumschalter

This commit is contained in:
2026-07-18 08:33:25 +02:00
18 changed files with 488 additions and 90 deletions
+17
View File
@@ -4,6 +4,23 @@ Alle nennenswerten Änderungen an diesem Projekt werden hier dokumentiert.
Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.1.0/),
Versionierung nach [Semantic Versioning](https://semver.org/lang/de/). Versionierung nach [Semantic Versioning](https://semver.org/lang/de/).
## [1.4.0] – 2026-07-18
### Hinzugefügt
- Essenszeiten (Brotzeit/Mittagessen/…) vollständig verwaltbar: anlegen,
umbenennen (inkl. EN/PL), Uhrzeit ändern, löschen und per Pfeil-Buttons
neu anordnen – neue Seite „Essenszeiten" im Kantinen-Bereich. Löschen
entfernt zugewiesene Gerichte automatisch aus allen Wochenplänen.
### Geändert
- „Automatisch übersetzen"-Button bei Gerichten/Allergenen/Zusatzstoffen
ist nur noch sichtbar, wenn `DEEPL_API_KEY` gesetzt ist, statt erst
nach Klick auf die fehlende Konfiguration hinzuweisen.
### Behoben
- Sprachumschalter (DE/EN/PL) erschien auf der öffentlichen Speisekarte
in der mobilen Ansicht doppelt.
## [1.3.0] – 2026-07-18 ## [1.3.0] – 2026-07-18
### Hinzugefügt ### Hinzugefügt
+4 -4
View File
@@ -14,8 +14,8 @@ markieren die jeweiligen Versionsstände (`git tag`).
| Bereich | Funktionen | | Bereich | Funktionen |
| --- | --- | | --- | --- |
| Öffentlich (ohne Login) | Wochenplan Mo–Fr mit den Bereichen Brotzeit (09:00–10:00), Mittagessen, Mittagessen vegetarisch und Suppe (je 11:30–13:30); Navigation vorherige/nächste Woche/Heute; auf schmalen Bildschirmen umschaltbare Tages-/Wochenansicht (Tag ist Standard); Logo zentriert über der Karte; Allergen-/Zusatzstoff-Kürzel je Gericht, Legende unterhalb der Karte; Sprachumschalter Deutsch/Englisch/Polnisch; Rezeptvorschläge einreichen | | Öffentlich (ohne Login) | Wochenplan Mo–Fr mit den Essenszeiten Brotzeit (09:00–10:00), Mittagessen, Mittagessen vegetarisch und Suppe (je 11:30–13:30); Navigation vorherige/nächste Woche/Heute; auf schmalen Bildschirmen umschaltbare Tages-/Wochenansicht (Tag ist Standard); Logo zentriert über der Karte; Allergen-/Zusatzstoff-Kürzel je Gericht, Legende unterhalb der Karte; Sprachumschalter Deutsch/Englisch/Polnisch; Rezeptvorschläge einreichen |
| Kantinen-Team (`/intern`) | Gerichte anlegen/bearbeiten/löschen (inkl. Live-Suche, Mehrfachauswahl zum Batch-Löschen, englischem/polnischem Namen mit automatischer DeepL-Übersetzung) mit Allergenen/Zusatzstoffen je Gericht; Gerichte Tagen/Bereichen zuweisen (mehrere pro Zelle) und wieder entfernen; globale Allergen-/Zusatzstofflisten pflegen (ebenfalls mehrsprachig); eingereichte Rezeptvorschläge sichten, übernehmen oder ablehnen; eigenes Konto verwalten (Name, E-Mail, Passwort, Passkeys, Passwort-Anmeldung an/aus) | | Kantinen-Team (`/intern`) | Gerichte anlegen/bearbeiten/löschen (inkl. Live-Suche, Mehrfachauswahl zum Batch-Löschen, englischem/polnischem Namen mit automatischer DeepL-Übersetzung) mit Allergenen/Zusatzstoffen je Gericht; Gerichte Tagen/Essenszeiten zuweisen (mehrere pro Zelle) und wieder entfernen; globale Allergen-/Zusatzstofflisten pflegen (ebenfalls mehrsprachig); eingereichte Rezeptvorschläge sichten, übernehmen oder ablehnen; eigenes Konto verwalten (Name, E-Mail, Passwort, Passkeys, Passwort-Anmeldung an/aus) |
| Admin (`/admin`) | Kantinenmitarbeiter anlegen/bearbeiten/löschen (inkl. Passwort-Reset mit Wiederholung und Rollenvergabe); Farbgebung der Speisekarte anpassen; Logo und Favicon hochladen/entfernen | | Admin (`/admin`) | Kantinenmitarbeiter anlegen/bearbeiten/löschen (inkl. Passwort-Reset mit Wiederholung und Rollenvergabe); Farbgebung der Speisekarte anpassen; Logo und Favicon hochladen/entfernen |
Anmeldung mit E-Mail + Passwort oder alternativ per **Passkey** Anmeldung mit E-Mail + Passwort oder alternativ per **Passkey**
@@ -24,7 +24,7 @@ Passwort-Anmeldung kann pro Konto deaktiviert werden, sobald mindestens
ein Passkey registriert ist. ein Passkey registriert ist.
Die gesamte Oberfläche ist auf Deutsch, Englisch und Polnisch verfügbar Die gesamte Oberfläche ist auf Deutsch, Englisch und Polnisch verfügbar
(Sprachumschalter, Cookie-basiert). Gerichte, Bereiche, Allergene und (Sprachumschalter, Cookie-basiert). Gerichte, Essenszeiten, Allergene und
Zusatzstoffe lassen sich zusätzlich auf Englisch/Polnisch benennen – Zusatzstoffe lassen sich zusätzlich auf Englisch/Polnisch benennen –
mit optionaler automatischer Übersetzung über DeepL (siehe mit optionaler automatischer Übersetzung über DeepL (siehe
`DEEPL_API_KEY` unten) und jederzeit manuell korrigierbar. `DEEPL_API_KEY` unten) und jederzeit manuell korrigierbar.
@@ -136,7 +136,7 @@ ihre Gültigkeit und müssen neu registriert werden.
Sicherheitsmodell (Row Level Security): Sicherheitsmodell (Row Level Security):
- `anon` darf ausschließlich lesen (Speisekarte, Bereiche, Allergene, - `anon` darf ausschließlich lesen (Speisekarte, Essenszeiten, Allergene,
Zusatzstoffe, Einstellungen). Zusatzstoffe, Einstellungen).
- `staff`/`admin` (Rolle im `profiles`-Eintrag) pflegen Gerichte, - `staff`/`admin` (Rolle im `profiles`-Eintrag) pflegen Gerichte,
Wochenplan und Kennzeichnungen. Wochenplan und Kennzeichnungen.
+5
View File
@@ -0,0 +1,5 @@
import SectionManager from "@/components/SectionManager";
export default function EssenszeitenPage() {
return <SectionManager />;
}
+1 -1
View File
@@ -1,5 +1,5 @@
import DishManager from "@/components/DishManager"; import DishManager from "@/components/DishManager";
export default function GerichtePage() { export default function GerichtePage() {
return <DishManager />; return <DishManager translateEnabled={!!process.env.DEEPL_API_KEY} />;
} }
+3 -2
View File
@@ -1,10 +1,11 @@
import CodeListManager from "@/components/CodeListManager"; import CodeListManager from "@/components/CodeListManager";
export default function KennzeichnungPage() { export default function KennzeichnungPage() {
const translateEnabled = !!process.env.DEEPL_API_KEY;
return ( return (
<div className="grid gap-6 lg:grid-cols-2"> <div className="grid gap-6 lg:grid-cols-2">
<CodeListManager table="allergens" /> <CodeListManager table="allergens" translateEnabled={translateEnabled} />
<CodeListManager table="additives" /> <CodeListManager table="additives" translateEnabled={translateEnabled} />
</div> </div>
); );
} }
+4
View File
@@ -63,7 +63,11 @@ export default async function Home({
kollidieren. */} kollidieren. */}
<header className="mb-6"> <header className="mb-6">
<div className="flex justify-end items-center gap-3 mb-2"> <div className="flex justify-end items-center gap-3 mb-2">
{/* Auf Mobilgeräten übernimmt PublicMobileMenu den
Sprachumschalter, hier würde er sonst doppelt erscheinen. */}
<div className="hidden sm:block">
<LanguageSwitcher variant="light" /> <LanguageSwitcher variant="light" />
</div>
<Link <Link
href="/login" href="/login"
className="text-sm text-gray-500 hover:text-[var(--c-primary)]" className="text-sm text-gray-500 hover:text-[var(--c-primary)]"
+1
View File
@@ -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/essenszeiten", 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 },
+7 -12
View File
@@ -12,8 +12,10 @@ import { useDict, useLocale } from "@/components/i18n/LocaleProvider";
export default function CodeListManager({ export default function CodeListManager({
table, table,
translateEnabled,
}: { }: {
table: "allergens" | "additives"; table: "allergens" | "additives";
translateEnabled: boolean;
}) { }) {
const supabase = useMemo(() => createClient(), []); const supabase = useMemo(() => createClient(), []);
const dict = useDict(); const dict = useDict();
@@ -34,9 +36,9 @@ export default function CodeListManager({
namePl: string; namePl: string;
} | null>(null); } | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [translateState, setTranslateState] = useState< const [translateState, setTranslateState] = useState<"idle" | "busy" | "done">(
"idle" | "busy" | "done" | "unavailable" "idle"
>("idle"); );
const load = useCallback(async () => { const load = useCallback(async () => {
const { data, error } = await supabase.from(table).select("*").order("sort"); const { data, error } = await supabase.from(table).select("*").order("sort");
@@ -115,10 +117,6 @@ export default function CodeListManager({
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? `Fehler (${res.status})`); if (!res.ok) throw new Error(data?.error ?? `Fehler (${res.status})`);
if (data.configured === false) {
setTranslateState("unavailable");
return;
}
setEditing((prev) => setEditing((prev) =>
prev ? { ...prev, nameEn: data.en ?? prev.nameEn, namePl: data.pl ?? prev.namePl } : prev prev ? { ...prev, nameEn: data.en ?? prev.nameEn, namePl: data.pl ?? prev.namePl } : prev
); );
@@ -191,6 +189,7 @@ export default function CodeListManager({
/> />
</div> </div>
</div> </div>
{translateEnabled && (
<div> <div>
<button <button
type="button" type="button"
@@ -207,12 +206,8 @@ export default function CodeListManager({
{dict.dishManager.translated} {dict.dishManager.translated}
</span> </span>
)} )}
{translateState === "unavailable" && (
<span className="ml-2 text-sm text-gray-500">
{dict.dishManager.translateUnavailable}
</span>
)}
</div> </div>
)}
<div className="flex gap-2"> <div className="flex gap-2">
<button type="submit" className="btn btn-add"> <button type="submit" className="btn btn-add">
{dict.common.save} {dict.common.save}
+10 -13
View File
@@ -29,7 +29,11 @@ const EMPTY_FORM: DishForm = {
additives: [], additives: [],
}; };
export default function DishManager() { export default function DishManager({
translateEnabled,
}: {
translateEnabled: boolean;
}) {
const supabase = useMemo(() => createClient(), []); const supabase = useMemo(() => createClient(), []);
const dict = useDict(); const dict = useDict();
const locale = useLocale(); const locale = useLocale();
@@ -42,9 +46,9 @@ export default function DishManager() {
const [selected, setSelected] = useState<Set<string>>(new Set()); const [selected, setSelected] = useState<Set<string>>(new Set());
const [bulkBusy, setBulkBusy] = useState(false); const [bulkBusy, setBulkBusy] = useState(false);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [translateState, setTranslateState] = useState< const [translateState, setTranslateState] = useState<"idle" | "busy" | "done">(
"idle" | "busy" | "done" | "unavailable" "idle"
>("idle"); );
// Live-Suche: filtert die Liste bereits während des Tippens // Live-Suche: filtert die Liste bereits während des Tippens
const visible = useMemo(() => { const visible = useMemo(() => {
@@ -199,10 +203,6 @@ export default function DishManager() {
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? `Fehler (${res.status})`); if (!res.ok) throw new Error(data?.error ?? `Fehler (${res.status})`);
if (data.configured === false) {
setTranslateState("unavailable");
return;
}
setForm((prev) => setForm((prev) =>
prev ? { ...prev, nameEn: data.en ?? prev.nameEn, namePl: data.pl ?? prev.namePl } : prev prev ? { ...prev, nameEn: data.en ?? prev.nameEn, namePl: data.pl ?? prev.namePl } : prev
); );
@@ -296,6 +296,7 @@ export default function DishManager() {
/> />
</div> </div>
</div> </div>
{translateEnabled && (
<div> <div>
<button <button
type="button" type="button"
@@ -312,12 +313,8 @@ export default function DishManager() {
{dict.dishManager.translated} {dict.dishManager.translated}
</span> </span>
)} )}
{translateState === "unavailable" && (
<span className="ml-2 text-sm text-gray-500">
{dict.dishManager.translateUnavailable}
</span>
)}
</div> </div>
)}
<ChipPicker <ChipPicker
label={dict.codeList.allergensTitle} label={dict.codeList.allergensTitle}
+298
View File
@@ -0,0 +1,298 @@
"use client";
// Verwaltung der Essenszeiten (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 = neue Essenszeit
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>
);
}
+57 -6
View File
@@ -30,6 +30,7 @@ const de = {
canteen: "Kantine", canteen: "Kantine",
menuGroup: "Speiseplan", menuGroup: "Speiseplan",
weekPlan: "Wochenplan", weekPlan: "Wochenplan",
sections: "Essenszeiten",
dishes: "Gerichte", dishes: "Gerichte",
labels: "Allergene & Zusatzstoffe", labels: "Allergene & Zusatzstoffe",
adminGroup: "Verwaltung", adminGroup: "Verwaltung",
@@ -87,8 +88,6 @@ const de = {
autoTranslate: "Automatisch übersetzen", autoTranslate: "Automatisch übersetzen",
translating: "Übersetze…", translating: "Übersetze…",
translated: "Übersetzt.", translated: "Übersetzt.",
translateUnavailable:
"Automatische Übersetzung ist nicht eingerichtet. Bitte Englisch/Polnisch manuell ausfüllen.",
priceLabel: "Preis in €", priceLabel: "Preis in €",
pricePlaceholder: "3,50", pricePlaceholder: "3,50",
searchPlaceholder: "Gericht suchen…", searchPlaceholder: "Gericht suchen…",
@@ -120,6 +119,24 @@ const de = {
saveError: "Speichern fehlgeschlagen: {message}", saveError: "Speichern fehlgeschlagen: {message}",
deleteError: "Löschen fehlgeschlagen: {message}", deleteError: "Löschen fehlgeschlagen: {message}",
}, },
sectionManager: {
title: "Essenszeiten",
newSection: "Neue Essenszeit",
editSection: "Essenszeit bearbeiten",
nameLabel: "Bezeichnung",
nameEnLabel: "Bezeichnung (Englisch)",
namePlLabel: "Bezeichnung (Polnisch)",
timeFrom: "Von",
timeTo: "Bis",
moveUp: "Nach oben",
moveDown: "Nach unten",
empty: "Noch keine Essenszeiten 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 +286,7 @@ const en: Dict = {
canteen: "Canteen", canteen: "Canteen",
menuGroup: "Menu", menuGroup: "Menu",
weekPlan: "Weekly plan", weekPlan: "Weekly plan",
sections: "Meal times",
dishes: "Dishes", dishes: "Dishes",
labels: "Allergens & Additives", labels: "Allergens & Additives",
adminGroup: "Administration", adminGroup: "Administration",
@@ -326,8 +344,6 @@ const en: Dict = {
autoTranslate: "Auto-translate", autoTranslate: "Auto-translate",
translating: "Translating…", translating: "Translating…",
translated: "Translated.", translated: "Translated.",
translateUnavailable:
"Automatic translation is not set up. Please fill in English/Polish manually.",
priceLabel: "Price in €", priceLabel: "Price in €",
pricePlaceholder: "3.50", pricePlaceholder: "3.50",
searchPlaceholder: "Search dishes…", searchPlaceholder: "Search dishes…",
@@ -359,6 +375,24 @@ const en: Dict = {
saveError: "Saving failed: {message}", saveError: "Saving failed: {message}",
deleteError: "Deleting failed: {message}", deleteError: "Deleting failed: {message}",
}, },
sectionManager: {
title: "Meal times",
newSection: "New meal time",
editSection: "Edit meal time",
nameLabel: "Name",
nameEnLabel: "Name (English)",
namePlLabel: "Name (Polish)",
timeFrom: "From",
timeTo: "To",
moveUp: "Move up",
moveDown: "Move down",
empty: "No meal times 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 +540,7 @@ const pl: Dict = {
canteen: "Stołówka", canteen: "Stołówka",
menuGroup: "Jadłospis", menuGroup: "Jadłospis",
weekPlan: "Plan tygodnia", weekPlan: "Plan tygodnia",
sections: "Pory posiłków",
dishes: "Dania", dishes: "Dania",
labels: "Alergeny i dodatki", labels: "Alergeny i dodatki",
adminGroup: "Administracja", adminGroup: "Administracja",
@@ -563,8 +598,6 @@ const pl: Dict = {
autoTranslate: "Przetłumacz automatycznie", autoTranslate: "Przetłumacz automatycznie",
translating: "Tłumaczenie…", translating: "Tłumaczenie…",
translated: "Przetłumaczono.", translated: "Przetłumaczono.",
translateUnavailable:
"Automatyczne tłumaczenie nie jest skonfigurowane. Wypełnij angielski/polski ręcznie.",
priceLabel: "Cena w €", priceLabel: "Cena w €",
pricePlaceholder: "3,50", pricePlaceholder: "3,50",
searchPlaceholder: "Szukaj dania…", searchPlaceholder: "Szukaj dania…",
@@ -596,6 +629,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: "Pory posiłków",
newSection: "Nowa pora posiłku",
editSection: "Edytuj porę posiłku",
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 pór posiłków.",
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",
+1 -1
View File
@@ -64,7 +64,7 @@ export function dishCodes(dish: { allergens: string[]; additives: string[] }): s
return [...(dish.allergens ?? []), ...(dish.additives ?? [])].join(","); return [...(dish.allergens ?? []), ...(dish.additives ?? [])].join(",");
} }
/** Einträge eines Tages/Bereichs aus der geladenen Wochenliste herausfiltern. */ /** Einträge eines Tages/einer Essenszeit aus der geladenen Wochenliste herausfiltern. */
export function entriesFor( export function entriesFor(
entries: MenuEntry[], entries: MenuEntry[],
iso: string, iso: string,
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "canteen-menu", "name": "canteen-menu",
"version": "1.3.0", "version": "1.4.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "canteen-menu", "name": "canteen-menu",
"version": "1.3.0", "version": "1.4.0",
"dependencies": { "dependencies": {
"@supabase/ssr": "^0.12.3", "@supabase/ssr": "^0.12.3",
"@supabase/supabase-js": "^2.110.7", "@supabase/supabase-js": "^2.110.7",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "canteen-menu", "name": "canteen-menu",
"version": "1.3.0", "version": "1.4.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev -H 0.0.0.0", "dev": "next dev -H 0.0.0.0",
+9 -5
View File
@@ -22,9 +22,6 @@ SET row_security = off;
-- Name: public; Type: SCHEMA; Schema: -; Owner: - -- Name: public; Type: SCHEMA; Schema: -; Owner: -
-- --
-- Das "public"-Schema existiert im offiziellen postgres-Image bereits;
-- CREATE SCHEMA würde dort mit ON_ERROR_STOP=1 das komplette Init-Skript
-- abbrechen.
CREATE SCHEMA IF NOT EXISTS public; CREATE SCHEMA IF NOT EXISTS public;
@@ -191,7 +188,7 @@ CREATE TABLE public.profiles (
-- --
CREATE TABLE public.sections ( CREATE TABLE public.sections (
key text NOT NULL, key text DEFAULT (gen_random_uuid())::text NOT NULL,
label text NOT NULL, label text NOT NULL,
time_range text NOT NULL, time_range text NOT NULL,
sort integer NOT NULL, sort integer NOT NULL,
@@ -320,7 +317,7 @@ ALTER TABLE ONLY public.menu_entries
-- --
ALTER TABLE ONLY public.menu_entries ALTER TABLE ONLY public.menu_entries
ADD CONSTRAINT menu_entries_section_key_fkey FOREIGN KEY (section_key) REFERENCES public.sections(key); ADD CONSTRAINT menu_entries_section_key_fkey FOREIGN KEY (section_key) REFERENCES public.sections(key) ON DELETE CASCADE;
-- --
@@ -471,6 +468,13 @@ ALTER TABLE public.sections ENABLE ROW LEVEL SECURITY;
CREATE POLICY sections_public_read ON public.sections FOR SELECT TO authenticated, anon USING (true); CREATE POLICY sections_public_read ON public.sections FOR SELECT TO authenticated, anon USING (true);
--
-- Name: sections sections_staff_write; Type: POLICY; Schema: public; Owner: -
--
CREATE POLICY sections_staff_write ON public.sections TO authenticated USING ((public.app_role() = ANY (ARRAY['staff'::text, 'admin'::text]))) WITH CHECK ((public.app_role() = ANY (ARRAY['staff'::text, 'admin'::text])));
-- --
-- Name: settings; Type: ROW SECURITY; Schema: public; Owner: - -- Name: settings; Type: ROW SECURITY; Schema: public; Owner: -
-- --
+6 -6
View File
@@ -2,7 +2,7 @@
-- PostgreSQL database dump -- PostgreSQL database dump
-- --
\restrict m2Vnc4kNXrHeKWS4dSX5SqMU5nUOxMfipXDVyLuYabYYzEbAHutmaRS8m10Dsdh \restrict y6aLgjBTAX9fiLwtkHEGaN6baJ1dCr2Yjdb6nmrL5LPA6jgf5Ml1Mm0EpXO56Kk
-- Dumped from database version 17.6 -- Dumped from database version 17.6
-- Dumped by pg_dump version 17.10 -- Dumped by pg_dump version 17.10
@@ -120,10 +120,10 @@ ALTER TABLE public.dishes ENABLE TRIGGER ALL;
ALTER TABLE public.sections DISABLE TRIGGER ALL; ALTER TABLE public.sections DISABLE TRIGGER ALL;
COPY public.sections (key, label, time_range, sort, label_en, label_pl) FROM stdin; COPY public.sections (key, label, time_range, sort, label_en, label_pl) FROM stdin;
brotzeit Brotzeit 09:00 - 10:00 1 Breakfast Śniadanie mittagessen_veg Mittagessen vegetarisch 11:30 - 13:30 4 Lunch (vegetarian) Obiad (wegetariański)
mittagessen Mittagessen 11:30 - 13:30 2 Lunch Obiad mittagessen Mittagessen 11:30 - 13:30 3 Lunch Obiad
mittagessen_veg Mittagessen vegetarisch 11:30 - 13:30 3 Lunch (vegetarian) Obiad (wegetariański) brotzeit Brotzeit 09:00 - 10:00 2 Breakfast Śniadanie
suppe Suppe 11:30 - 13:30 4 Soup Zupa suppe Suppe 11:30 - 13:30 6 Soup Zupa
\. \.
@@ -208,5 +208,5 @@ ALTER TABLE public.settings ENABLE TRIGGER ALL;
-- PostgreSQL database dump complete -- PostgreSQL database dump complete
-- --
\unrestrict m2Vnc4kNXrHeKWS4dSX5SqMU5nUOxMfipXDVyLuYabYYzEbAHutmaRS8m10Dsdh \unrestrict y6aLgjBTAX9fiLwtkHEGaN6baJ1dCr2Yjdb6nmrL5LPA6jgf5Ml1Mm0EpXO56Kk
+2 -2
View File
@@ -49,7 +49,7 @@ create trigger on_auth_user_created
after insert on auth.users after insert on auth.users
for each row execute function public.handle_new_user(); for each row execute function public.handle_new_user();
-- ---------- Bereiche der Speisekarte ---------- -- ---------- Essenszeiten der Speisekarte ----------
create table public.sections ( create table public.sections (
key text primary key, key text primary key,
label text not null, label text not null,
@@ -101,7 +101,7 @@ create trigger dishes_touch_updated_at
before update on public.dishes before update on public.dishes
for each row execute function public.touch_updated_at(); for each row execute function public.touch_updated_at();
-- ---------- Zuweisung Gericht -> Tag/Bereich ---------- -- ---------- Zuweisung Gericht -> Tag/Essenszeit ----------
create table public.menu_entries ( create table public.menu_entries (
id uuid primary key default gen_random_uuid(), id uuid primary key default gen_random_uuid(),
menu_date date not null, menu_date date not null,
@@ -0,0 +1,25 @@
-- =============================================================
-- Essenszeiten (Brotzeit/Mittagessen/…) vollständig verwaltbar machen:
-- anlegen, umbenennen, Uhrzeit ändern, löschen.
-- =============================================================
-- Neue Essenszeiten 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 eine Essenszeit, werden ihre 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 Essenszeiten 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'));