Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c5376a8c7 | ||
|
|
0a1f533880 | ||
|
|
4d5e7fca04 | ||
|
|
0f20ad6527 | ||
|
|
d85d2e3fe3 | ||
|
|
a80cd5bd12 | ||
|
|
51fdb93e97 | ||
|
|
868a5d2fcf |
@@ -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/),
|
||||
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
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
@@ -14,8 +14,8 @@ markieren die jeweiligen Versionsstände (`git tag`).
|
||||
|
||||
| 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 |
|
||||
| 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) |
|
||||
| Ö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/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 |
|
||||
|
||||
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.
|
||||
|
||||
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 –
|
||||
mit optionaler automatischer Übersetzung über DeepL (siehe
|
||||
`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):
|
||||
|
||||
- `anon` darf ausschließlich lesen (Speisekarte, Bereiche, Allergene,
|
||||
- `anon` darf ausschließlich lesen (Speisekarte, Essenszeiten, Allergene,
|
||||
Zusatzstoffe, Einstellungen).
|
||||
- `staff`/`admin` (Rolle im `profiles`-Eintrag) pflegen Gerichte,
|
||||
Wochenplan und Kennzeichnungen.
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import SectionManager from "@/components/SectionManager";
|
||||
|
||||
export default function EssenszeitenPage() {
|
||||
return <SectionManager />;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import DishManager from "@/components/DishManager";
|
||||
|
||||
export default function GerichtePage() {
|
||||
return <DishManager />;
|
||||
return <DishManager translateEnabled={!!process.env.DEEPL_API_KEY} />;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import CodeListManager from "@/components/CodeListManager";
|
||||
|
||||
export default function KennzeichnungPage() {
|
||||
const translateEnabled = !!process.env.DEEPL_API_KEY;
|
||||
return (
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<CodeListManager table="allergens" />
|
||||
<CodeListManager table="additives" />
|
||||
<CodeListManager table="allergens" translateEnabled={translateEnabled} />
|
||||
<CodeListManager table="additives" translateEnabled={translateEnabled} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -63,7 +63,11 @@ export default async function Home({
|
||||
kollidieren. */}
|
||||
<header className="mb-6">
|
||||
<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" />
|
||||
</div>
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-sm text-gray-500 hover:text-[var(--c-primary)]"
|
||||
|
||||
@@ -23,6 +23,7 @@ export default function AppHeader({
|
||||
|
||||
const menuLinks = [
|
||||
{ href: "/intern", label: dict.header.weekPlan },
|
||||
{ href: "/intern/essenszeiten", 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 },
|
||||
|
||||
@@ -12,8 +12,10 @@ import { useDict, useLocale } from "@/components/i18n/LocaleProvider";
|
||||
|
||||
export default function CodeListManager({
|
||||
table,
|
||||
translateEnabled,
|
||||
}: {
|
||||
table: "allergens" | "additives";
|
||||
translateEnabled: boolean;
|
||||
}) {
|
||||
const supabase = useMemo(() => createClient(), []);
|
||||
const dict = useDict();
|
||||
@@ -34,9 +36,9 @@ export default function CodeListManager({
|
||||
namePl: string;
|
||||
} | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [translateState, setTranslateState] = useState<
|
||||
"idle" | "busy" | "done" | "unavailable"
|
||||
>("idle");
|
||||
const [translateState, setTranslateState] = useState<"idle" | "busy" | "done">(
|
||||
"idle"
|
||||
);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const { data, error } = await supabase.from(table).select("*").order("sort");
|
||||
@@ -115,10 +117,6 @@ export default function CodeListManager({
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data?.error ?? `Fehler (${res.status})`);
|
||||
if (data.configured === false) {
|
||||
setTranslateState("unavailable");
|
||||
return;
|
||||
}
|
||||
setEditing((prev) =>
|
||||
prev ? { ...prev, nameEn: data.en ?? prev.nameEn, namePl: data.pl ?? prev.namePl } : prev
|
||||
);
|
||||
@@ -191,6 +189,7 @@ export default function CodeListManager({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{translateEnabled && (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -207,12 +206,8 @@ export default function CodeListManager({
|
||||
{dict.dishManager.translated}
|
||||
</span>
|
||||
)}
|
||||
{translateState === "unavailable" && (
|
||||
<span className="ml-2 text-sm text-gray-500">
|
||||
{dict.dishManager.translateUnavailable}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<button type="submit" className="btn btn-add">
|
||||
{dict.common.save}
|
||||
|
||||
+10
-13
@@ -29,7 +29,11 @@ const EMPTY_FORM: DishForm = {
|
||||
additives: [],
|
||||
};
|
||||
|
||||
export default function DishManager() {
|
||||
export default function DishManager({
|
||||
translateEnabled,
|
||||
}: {
|
||||
translateEnabled: boolean;
|
||||
}) {
|
||||
const supabase = useMemo(() => createClient(), []);
|
||||
const dict = useDict();
|
||||
const locale = useLocale();
|
||||
@@ -42,9 +46,9 @@ export default function DishManager() {
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [bulkBusy, setBulkBusy] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const [translateState, setTranslateState] = useState<
|
||||
"idle" | "busy" | "done" | "unavailable"
|
||||
>("idle");
|
||||
const [translateState, setTranslateState] = useState<"idle" | "busy" | "done">(
|
||||
"idle"
|
||||
);
|
||||
|
||||
// Live-Suche: filtert die Liste bereits während des Tippens
|
||||
const visible = useMemo(() => {
|
||||
@@ -199,10 +203,6 @@ export default function DishManager() {
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data?.error ?? `Fehler (${res.status})`);
|
||||
if (data.configured === false) {
|
||||
setTranslateState("unavailable");
|
||||
return;
|
||||
}
|
||||
setForm((prev) =>
|
||||
prev ? { ...prev, nameEn: data.en ?? prev.nameEn, namePl: data.pl ?? prev.namePl } : prev
|
||||
);
|
||||
@@ -296,6 +296,7 @@ export default function DishManager() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{translateEnabled && (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -312,12 +313,8 @@ export default function DishManager() {
|
||||
{dict.dishManager.translated}
|
||||
</span>
|
||||
)}
|
||||
{translateState === "unavailable" && (
|
||||
<span className="ml-2 text-sm text-gray-500">
|
||||
{dict.dishManager.translateUnavailable}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ChipPicker
|
||||
label={dict.codeList.allergensTitle}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -30,6 +30,7 @@ const de = {
|
||||
canteen: "Kantine",
|
||||
menuGroup: "Speiseplan",
|
||||
weekPlan: "Wochenplan",
|
||||
sections: "Essenszeiten",
|
||||
dishes: "Gerichte",
|
||||
labels: "Allergene & Zusatzstoffe",
|
||||
adminGroup: "Verwaltung",
|
||||
@@ -87,8 +88,6 @@ const de = {
|
||||
autoTranslate: "Automatisch übersetzen",
|
||||
translating: "Übersetze…",
|
||||
translated: "Übersetzt.",
|
||||
translateUnavailable:
|
||||
"Automatische Übersetzung ist nicht eingerichtet. Bitte Englisch/Polnisch manuell ausfüllen.",
|
||||
priceLabel: "Preis in €",
|
||||
pricePlaceholder: "3,50",
|
||||
searchPlaceholder: "Gericht suchen…",
|
||||
@@ -120,6 +119,24 @@ const de = {
|
||||
saveError: "Speichern 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: {
|
||||
title: "Benutzer",
|
||||
newUser: "Neuer Benutzer",
|
||||
@@ -269,6 +286,7 @@ const en: Dict = {
|
||||
canteen: "Canteen",
|
||||
menuGroup: "Menu",
|
||||
weekPlan: "Weekly plan",
|
||||
sections: "Meal times",
|
||||
dishes: "Dishes",
|
||||
labels: "Allergens & Additives",
|
||||
adminGroup: "Administration",
|
||||
@@ -326,8 +344,6 @@ const en: Dict = {
|
||||
autoTranslate: "Auto-translate",
|
||||
translating: "Translating…",
|
||||
translated: "Translated.",
|
||||
translateUnavailable:
|
||||
"Automatic translation is not set up. Please fill in English/Polish manually.",
|
||||
priceLabel: "Price in €",
|
||||
pricePlaceholder: "3.50",
|
||||
searchPlaceholder: "Search dishes…",
|
||||
@@ -359,6 +375,24 @@ const en: Dict = {
|
||||
saveError: "Saving 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: {
|
||||
title: "Users",
|
||||
newUser: "New user",
|
||||
@@ -506,6 +540,7 @@ const pl: Dict = {
|
||||
canteen: "Stołówka",
|
||||
menuGroup: "Jadłospis",
|
||||
weekPlan: "Plan tygodnia",
|
||||
sections: "Pory posiłków",
|
||||
dishes: "Dania",
|
||||
labels: "Alergeny i dodatki",
|
||||
adminGroup: "Administracja",
|
||||
@@ -563,8 +598,6 @@ const pl: Dict = {
|
||||
autoTranslate: "Przetłumacz automatycznie",
|
||||
translating: "Tłumaczenie…",
|
||||
translated: "Przetłumaczono.",
|
||||
translateUnavailable:
|
||||
"Automatyczne tłumaczenie nie jest skonfigurowane. Wypełnij angielski/polski ręcznie.",
|
||||
priceLabel: "Cena w €",
|
||||
pricePlaceholder: "3,50",
|
||||
searchPlaceholder: "Szukaj dania…",
|
||||
@@ -596,6 +629,24 @@ const pl: Dict = {
|
||||
saveError: "Zapisywanie 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: {
|
||||
title: "Użytkownicy",
|
||||
newUser: "Nowy użytkownik",
|
||||
|
||||
+1
-1
@@ -64,7 +64,7 @@ export function dishCodes(dish: { allergens: string[]; additives: string[] }): s
|
||||
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(
|
||||
entries: MenuEntry[],
|
||||
iso: string,
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "canteen-menu",
|
||||
"version": "1.3.0",
|
||||
"version": "1.4.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "canteen-menu",
|
||||
"version": "1.3.0",
|
||||
"version": "1.4.0",
|
||||
"dependencies": {
|
||||
"@supabase/ssr": "^0.12.3",
|
||||
"@supabase/supabase-js": "^2.110.7",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "canteen-menu",
|
||||
"version": "1.3.0",
|
||||
"version": "1.4.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -H 0.0.0.0",
|
||||
|
||||
@@ -22,9 +22,6 @@ SET row_security = off;
|
||||
-- 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;
|
||||
|
||||
|
||||
@@ -191,7 +188,7 @@ CREATE TABLE public.profiles (
|
||||
--
|
||||
|
||||
CREATE TABLE public.sections (
|
||||
key text NOT NULL,
|
||||
key text DEFAULT (gen_random_uuid())::text NOT NULL,
|
||||
label text NOT NULL,
|
||||
time_range text NOT NULL,
|
||||
sort integer NOT NULL,
|
||||
@@ -320,7 +317,7 @@ 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);
|
||||
|
||||
|
||||
--
|
||||
-- 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: -
|
||||
--
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
-- PostgreSQL database dump
|
||||
--
|
||||
|
||||
\restrict m2Vnc4kNXrHeKWS4dSX5SqMU5nUOxMfipXDVyLuYabYYzEbAHutmaRS8m10Dsdh
|
||||
\restrict y6aLgjBTAX9fiLwtkHEGaN6baJ1dCr2Yjdb6nmrL5LPA6jgf5Ml1Mm0EpXO56Kk
|
||||
|
||||
-- Dumped from database version 17.6
|
||||
-- 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;
|
||||
|
||||
COPY public.sections (key, label, time_range, sort, label_en, label_pl) FROM stdin;
|
||||
brotzeit Brotzeit 09:00 - 10:00 1 Breakfast Śniadanie
|
||||
mittagessen Mittagessen 11:30 - 13:30 2 Lunch Obiad
|
||||
mittagessen_veg Mittagessen vegetarisch 11:30 - 13:30 3 Lunch (vegetarian) Obiad (wegetariański)
|
||||
suppe Suppe 11:30 - 13:30 4 Soup Zupa
|
||||
mittagessen_veg Mittagessen vegetarisch 11:30 - 13:30 4 Lunch (vegetarian) Obiad (wegetariański)
|
||||
mittagessen Mittagessen 11:30 - 13:30 3 Lunch Obiad
|
||||
brotzeit Brotzeit 09:00 - 10:00 2 Breakfast Śniadanie
|
||||
suppe Suppe 11:30 - 13:30 6 Soup Zupa
|
||||
\.
|
||||
|
||||
|
||||
@@ -208,5 +208,5 @@ ALTER TABLE public.settings ENABLE TRIGGER ALL;
|
||||
-- PostgreSQL database dump complete
|
||||
--
|
||||
|
||||
\unrestrict m2Vnc4kNXrHeKWS4dSX5SqMU5nUOxMfipXDVyLuYabYYzEbAHutmaRS8m10Dsdh
|
||||
\unrestrict y6aLgjBTAX9fiLwtkHEGaN6baJ1dCr2Yjdb6nmrL5LPA6jgf5Ml1Mm0EpXO56Kk
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ create trigger on_auth_user_created
|
||||
after insert on auth.users
|
||||
for each row execute function public.handle_new_user();
|
||||
|
||||
-- ---------- Bereiche der Speisekarte ----------
|
||||
-- ---------- Essenszeiten der Speisekarte ----------
|
||||
create table public.sections (
|
||||
key text primary key,
|
||||
label text not null,
|
||||
@@ -101,7 +101,7 @@ create trigger dishes_touch_updated_at
|
||||
before update on public.dishes
|
||||
for each row execute function public.touch_updated_at();
|
||||
|
||||
-- ---------- Zuweisung Gericht -> Tag/Bereich ----------
|
||||
-- ---------- Zuweisung Gericht -> Tag/Essenszeit ----------
|
||||
create table public.menu_entries (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
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'));
|
||||
Reference in New Issue
Block a user