Compare commits

...
6 Commits
Author SHA1 Message Date
florian.wilde 8b3921c50e Merge fix/closed-day-icon-and-banner: Schloss-Symbol + durchgehendes Geschlossen-Banner 2026-07-20 12:49:46 +02:00
florian.wilde 63870ba2e6 fix: Schloss-Symbol statt Text + durchgehendes Geschlossen-Banner
Ersetzt den "Tag schließen"-Textlink durch ein Schloss-Symbol
(🔓/🔒) und lässt den "Geschlossen"-Hinweis eines geschlossenen
Tages über alle Essenszeiten-Zeilen durchlaufen (per rowSpan), statt
ihn in jeder Zeile einzeln zu wiederholen – sowohl im Wochenplan als
auch auf der öffentlichen Speisekarte.
2026-07-20 12:49:04 +02:00
florian.wilde e9d4b51fce Merge feature/v1.12.0: Tage als geschlossen markierbar (Feiertage o. Ä.) 2026-07-20 12:35:31 +02:00
florian.wilde 2a2c5affd5 feat: Tage als geschlossen markierbar (Feiertage o. Ä.)
Im Wochenplan lässt sich pro Tag ein optionaler, mehrsprachiger
Schließungsgrund hinterlegen. Geschlossene Tage werden auf der
öffentlichen Speisekarte (Desktop + mobil) und im RSS-Feed als
"Geschlossen" statt mit Essenszeiten angezeigt, im Wochenplan bleibt
"Mahlzeit hinzufügen" für diesen Tag deaktiviert.
2026-07-20 12:35:00 +02:00
florian.wilde 0c05a16f09 Merge fix/locale-fallback-inactive-german: Sprach-Fallback ignoriert deaktiviertes Deutsch 2026-07-20 11:23:30 +02:00
florian.wilde c98a3617a4 fix: Sprach-Fallback ignoriert deaktiviertes Deutsch
getLocale() fiel bei fehlendem/ungültigem Cookie bisher immer fest auf
Deutsch zurück, auch wenn Deutsch im Sprachen-Admin deaktiviert wurde.
Da der Sprachumschalter sich bei nur einer aktiven Sprache zusätzlich
ausblendet, gab es keine Möglichkeit, das über die Oberfläche zu
korrigieren. Fallback nutzt jetzt Deutsch nur noch, wenn es aktiv ist.
2026-07-20 11:22:49 +02:00
17 changed files with 594 additions and 100 deletions
+44
View File
@@ -4,6 +4,50 @@ 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.12.1] – 2026-07-20
### Geändert
- Bedienung geschlossener Tage im Wochenplan überarbeitet: Statt eines
Text-Links am Datum markiert jetzt ein Schloss-Symbol (🔓 zum
Schließen, 🔒 zeigt „geschlossen" an und öffnet per Klick wieder) den
Tag. Ein geschlossener Tag zeigt sowohl im Wochenplan als auch auf
der öffentlichen Speisekarte (Desktop-Tabelle) einen einzigen, über
alle Essenszeiten-Zeilen durchgehenden „Geschlossen"-Hinweis statt
einer Wiederholung in jeder einzelnen Zeile.
## [1.12.0] – 2026-07-20
### Hinzugefügt
- Einzelne Tage als geschlossen markierbar (z. B. Feiertage,
Betriebsausflug) – im Wochenplan lässt sich pro Tag über „Tag
schließen" ein optionaler, mehrsprachiger Grund hinterlegen (inkl.
automatischer Übersetzung per DeepL, sofern konfiguriert). Ein
geschlossener Tag:
- blendet in der Gerichte-Verwaltung des Wochenplans „Mahlzeit
hinzufügen" für diesen Tag aus (bestehende Einträge lassen sich
weiterhin entfernen), lässt sich per „Wieder öffnen" jederzeit
rückgängig machen.
- zeigt auf der öffentlichen Speisekarte (Desktop-Tabelle und
mobile Ansicht) einen „Geschlossen"-Hinweis mit Grund statt der
Essenszeiten-Spalten/-Karte für diesen Tag.
- erscheint im RSS-Feed als eigener Eintrag mit Schließungsgrund
statt eines leeren oder fehlenden Tages.
- Neue Tabellen `closed_days`/`closed_day_translations` (Migration
`0007_closed_days.sql`).
## [1.11.1] – 2026-07-20
### Behoben
- Wurde Deutsch im Sprachen-Admin deaktiviert (z. B. um die App auf nur
Englisch zu reduzieren), blieb die gesamte App trotzdem auf Deutsch
stehen, sowohl im öffentlichen als auch im internen Bereich: `getLocale()`
fiel bei fehlendem/ungültigem Sprach-Cookie bisher immer fest auf
Deutsch zurück, unabhängig davon, ob Deutsch überhaupt noch aktiv war.
Da der Sprachumschalter sich bei nur einer aktiven Sprache zusätzlich
automatisch ausblendet, gab es keine Möglichkeit, das über die
Oberfläche zu korrigieren. Der Fallback nutzt jetzt Deutsch nur noch,
wenn es tatsächlich aktiv ist – sonst die erste aktive Sprache.
## [1.11.0] – 2026-07-20 ## [1.11.0] – 2026-07-20
### Hinzugefügt ### Hinzugefügt
+23
View File
@@ -0,0 +1,23 @@
import { NextResponse } from "next/server";
import { removeClosedDay } from "@/lib/db/closed-days";
import { authErrorResponse, requireRole } from "@/lib/auth/session";
import { isSameOrigin } from "@/lib/origin";
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ date: string }> }
) {
if (!isSameOrigin(request)) {
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
}
try {
await requireRole(["staff", "admin"]);
const { date } = await params;
await removeClosedDay(date);
return NextResponse.json({ ok: true });
} catch (err) {
const res = authErrorResponse(err);
if (res) return res;
throw err;
}
}
+34
View File
@@ -0,0 +1,34 @@
import { NextResponse } from "next/server";
import { listClosedDays, setClosedDay } from "@/lib/db/closed-days";
import { authErrorResponse, requireRole } from "@/lib/auth/session";
import { isSameOrigin } from "@/lib/origin";
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const from = searchParams.get("from");
const to = searchParams.get("to");
if (!from || !to) {
return NextResponse.json({ error: "invalid_input" }, { status: 400 });
}
return NextResponse.json(await listClosedDays(from, to));
}
export async function POST(request: Request) {
if (!isSameOrigin(request)) {
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
}
try {
await requireRole(["staff", "admin"]);
const body = (await request.json()) as {
closed_date: string;
reason: string | null;
translations?: Record<string, string | null>;
};
const closedDay = await setClosedDay(body.closed_date, body.reason, body.translations);
return NextResponse.json(closedDay, { status: 201 });
} catch (err) {
const res = authErrorResponse(err);
if (res) return res;
throw err;
}
}
+1
View File
@@ -14,6 +14,7 @@ const ALLOWED_TABLES: Record<string, { idColumn: string; translationTable: Trans
sections: { idColumn: "key", translationTable: "section_translations" }, sections: { idColumn: "key", translationTable: "section_translations" },
allergens: { idColumn: "code", translationTable: "allergen_translations" }, allergens: { idColumn: "code", translationTable: "allergen_translations" },
additives: { idColumn: "code", translationTable: "additive_translations" }, additives: { idColumn: "code", translationTable: "additive_translations" },
closed_days: { idColumn: "closed_date", translationTable: "closed_day_translations" },
}; };
// Unsere Sprachcodes -> DeepL target_lang. Sprachen ohne Eintrag hier sind // Unsere Sprachcodes -> DeepL target_lang. Sprachen ohne Eintrag hier sind
+25 -16
View File
@@ -1,5 +1,6 @@
import { query } from "@/lib/db"; import { query } from "@/lib/db";
import { loadMenuEntries } from "@/lib/db/menu-entries"; import { loadMenuEntries } from "@/lib/db/menu-entries";
import { listClosedDays } from "@/lib/db/closed-days";
import { addDays, entriesFor, formatPrice, mondayOf, todayISO } from "@/lib/menu"; import { addDays, entriesFor, formatPrice, mondayOf, todayISO } from "@/lib/menu";
import { localized, SITE_TITLE } from "@/lib/types"; import { localized, SITE_TITLE } from "@/lib/types";
import type { Section } from "@/lib/types"; import type { Section } from "@/lib/types";
@@ -45,30 +46,38 @@ export async function GET() {
// Wochennavigation anzeigt. // Wochennavigation anzeigt.
const rangeEnd = addDays(monday, 11); const rangeEnd = addDays(monday, 11);
const [sections, entries] = await Promise.all([ const [sections, entries, closedDays] = await Promise.all([
query<Section>("select * from sections order by sort"), query<Section>("select * from sections order by sort"),
loadMenuEntries(monday, rangeEnd), loadMenuEntries(monday, rangeEnd),
listClosedDays(monday, rangeEnd),
]); ]);
const days = Array.from(new Set(entries.map((e) => e.menu_date))).sort(); const days = Array.from(
new Set([...entries.map((e) => e.menu_date), ...closedDays.map((c) => c.closed_date)])
).sort();
const items = days const items = days
.map((iso) => { .map((iso) => {
const dayContent = sections const closed = closedDays.find((c) => c.closed_date === iso);
.map((s) => { const dayContent = closed
const dishes = entriesFor(entries, iso, s.key); ? `<p><strong>Geschlossen</strong>${
if (dishes.length === 0) return ""; closed.reason ? ` – ${escapeXml(closed.reason)}` : ""
const list = dishes }</p>`
.map( : sections
(e) => .map((s) => {
`<li>${escapeXml(localized(e.dish, "de"))} – ${formatPrice(e.dish.price)}</li>` const dishes = entriesFor(entries, iso, s.key);
) if (dishes.length === 0) return "";
const list = dishes
.map(
(e) =>
`<li>${escapeXml(localized(e.dish, "de"))} – ${formatPrice(e.dish.price)}</li>`
)
.join("");
return `<p><strong>${escapeXml(localized(s, "de"))} (${escapeXml(
s.time_range
)})</strong></p><ul>${list}</ul>`;
})
.join(""); .join("");
return `<p><strong>${escapeXml(localized(s, "de"))} (${escapeXml(
s.time_range
)})</strong></p><ul>${list}</ul>`;
})
.join("");
if (!dayContent) return ""; if (!dayContent) return "";
const [y, m, d] = iso.split("-").map(Number); const [y, m, d] = iso.split("-").map(Number);
+4
View File
@@ -102,6 +102,10 @@ body {
font-size: 0.85rem; font-size: 0.85rem;
} }
.menu-table tr:nth-child(even) td.cell { background: var(--c-row-bg-alt); } .menu-table tr:nth-child(even) td.cell { background: var(--c-row-bg-alt); }
.menu-table td.cell.closed-cell {
vertical-align: middle;
background: #fee2e2;
}
.menu-table td.cell .dish { margin-bottom: 0.35rem; } .menu-table td.cell .dish { margin-bottom: 0.35rem; }
.menu-table td.cell .dish sup { .menu-table td.cell .dish sup {
font-size: 0.6rem; font-size: 0.6rem;
+6 -1
View File
@@ -7,5 +7,10 @@ export default async function InternPage({
searchParams: Promise<{ woche?: string }>; searchParams: Promise<{ woche?: string }>;
}) { }) {
const { woche } = await searchParams; const { woche } = await searchParams;
return <WeekEditor monday={mondayOf(woche)} />; return (
<WeekEditor
monday={mondayOf(woche)}
translateEnabled={!!process.env.DEEPL_API_KEY}
/>
);
} }
+43 -17
View File
@@ -2,6 +2,7 @@ import Link from "next/link";
import { query } from "@/lib/db"; import { query } from "@/lib/db";
import { loadMenuEntries } from "@/lib/db/menu-entries"; import { loadMenuEntries } from "@/lib/db/menu-entries";
import { loadTranslations } from "@/lib/db/translations"; import { loadTranslations } from "@/lib/db/translations";
import { listClosedDays } from "@/lib/db/closed-days";
import { getSettings } from "@/lib/db/settings"; import { getSettings } from "@/lib/db/settings";
import { import {
addDays, addDays,
@@ -43,6 +44,7 @@ export default async function Home({
sectionTranslations, sectionTranslations,
allergenTranslations, allergenTranslations,
additiveTranslations, additiveTranslations,
closedDays,
] = await Promise.all([ ] = await Promise.all([
query<Omit<Section, "translations">>("select key, label, time_range, sort from sections order by sort"), query<Omit<Section, "translations">>("select key, label, time_range, sort from sections order by sort"),
loadMenuEntries(monday, friday), loadMenuEntries(monday, friday),
@@ -52,7 +54,9 @@ export default async function Home({
loadTranslations("section_translations"), loadTranslations("section_translations"),
loadTranslations("allergen_translations"), loadTranslations("allergen_translations"),
loadTranslations("additive_translations"), loadTranslations("additive_translations"),
listClosedDays(monday, friday),
]); ]);
const closedDayFor = (iso: string) => closedDays.find((c) => c.closed_date === iso);
const sections: Section[] = sectionRows.map((s) => ({ const sections: Section[] = sectionRows.map((s) => ({
...s, ...s,
@@ -130,6 +134,7 @@ export default async function Home({
monday={monday} monday={monday}
sections={sections} sections={sections}
entries={entries} entries={entries}
closedDays={closedDays}
showLabeling={labelingDetailed} showLabeling={labelingDetailed}
/> />
@@ -165,31 +170,52 @@ export default async function Home({
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{sections.map((s) => ( {sections.map((s, sIdx) => (
<tr key={s.key}> <tr key={s.key}>
<th className="section-head"> <th className="section-head">
<div>{localized(s, locale)}</div> <div>{localized(s, locale)}</div>
<div className="time">{s.time_range}</div> <div className="time">{s.time_range}</div>
</th> </th>
{days.map((d) => ( {days.map((d) => {
<td key={d.iso} className="cell"> const closed = closedDayFor(d.iso);
{entriesFor(entries, d.iso, s.key).map((e) => { if (closed) {
const codes = dishCodes(e.dish); if (sIdx > 0) return null;
return ( return (
<div key={e.id} className="dish"> <td key={d.iso} className="cell closed-cell" rowSpan={sections.length}>
<span className="font-bold"> <div className="flex flex-col items-center justify-center gap-1 py-4 text-red-700">
{localized(e.dish, locale)} <span className="text-2xl" aria-hidden>
🔒
</span> </span>
{labelingDetailed && codes && ( <span className="font-semibold">{dict.publicMenu.closedLabel}</span>
<sup className="dish-codes">{codes}</sup> {localized(closed, locale) && (
<span className="text-sm px-2 text-center">
{localized(closed, locale)}
</span>
)} )}
{" – "}
{formatPrice(e.dish.price)}
</div> </div>
); </td>
})} );
</td> }
))} return (
<td key={d.iso} className="cell">
{entriesFor(entries, d.iso, s.key).map((e) => {
const codes = dishCodes(e.dish);
return (
<div key={e.id} className="dish">
<span className="font-bold">
{localized(e.dish, locale)}
</span>
{labelingDetailed && codes && (
<sup className="dish-codes">{codes}</sup>
)}
{" – "}
{formatPrice(e.dish.price)}
</div>
);
})}
</td>
);
})}
</tr> </tr>
))} ))}
</tbody> </tbody>
+47 -36
View File
@@ -19,7 +19,7 @@ import {
weekdayIndex, weekdayIndex,
} from "@/lib/menu"; } from "@/lib/menu";
import { localized } from "@/lib/types"; import { localized } from "@/lib/types";
import type { MenuEntry, Section } from "@/lib/types"; import type { ClosedDay, MenuEntry, Section } from "@/lib/types";
import { useDict, useLocale } from "@/components/i18n/LocaleProvider"; import { useDict, useLocale } from "@/components/i18n/LocaleProvider";
import LanguageSwitcher from "@/components/i18n/LanguageSwitcher"; import LanguageSwitcher from "@/components/i18n/LanguageSwitcher";
@@ -27,11 +27,13 @@ export default function PublicMobileMenu({
monday, monday,
sections, sections,
entries, entries,
closedDays,
showLabeling, showLabeling,
}: { }: {
monday: string; monday: string;
sections: Section[]; sections: Section[];
entries: MenuEntry[]; entries: MenuEntry[];
closedDays: ClosedDay[];
showLabeling: boolean; showLabeling: boolean;
}) { }) {
const router = useRouter(); const router = useRouter();
@@ -60,47 +62,56 @@ export default function PublicMobileMenu({
} }
function renderDay(day: { weekday: string; iso: string }) { function renderDay(day: { weekday: string; iso: string }) {
const closed = closedDays.find((c) => c.closed_date === day.iso);
return ( return (
<div key={day.iso} className="mb-4 last:mb-0"> <div key={day.iso} className="mb-4 last:mb-0">
<h3 className="font-bold text-[var(--c-primary)] mb-2"> <h3 className="font-bold text-[var(--c-primary)] mb-2">
{day.weekday} · {formatDateLong(day.iso, dict.dateLocale)} {day.weekday} · {formatDateLong(day.iso, dict.dateLocale)}
</h3> </h3>
<div className="space-y-3"> {closed ? (
{sections.map((s) => { <div className="rounded-md border border-red-200 bg-red-50 px-3 py-3 text-sm text-red-700 text-center">
const items = entriesFor(entries, day.iso, s.key); <span aria-hidden>🔒</span>{" "}
return ( <span className="font-semibold">{dict.publicMenu.closedLabel}</span>
<div {localized(closed, locale) && <span> – {localized(closed, locale)}</span>}
key={s.key} </div>
className="rounded-md border border-gray-200 overflow-hidden" ) : (
> <div className="space-y-3">
<div className="bg-[var(--c-primary)] text-[var(--c-header-text)] px-3 py-1.5 text-sm"> {sections.map((s) => {
<span className="font-semibold">{localized(s, locale)}</span>{" "} const items = entriesFor(entries, day.iso, s.key);
<span className="opacity-80">· {s.time_range}</span> return (
<div
key={s.key}
className="rounded-md border border-gray-200 overflow-hidden"
>
<div className="bg-[var(--c-primary)] text-[var(--c-header-text)] px-3 py-1.5 text-sm">
<span className="font-semibold">{localized(s, locale)}</span>{" "}
<span className="opacity-80">· {s.time_range}</span>
</div>
<div className="bg-[var(--c-row-bg)] px-3 py-2 text-sm">
{items.length === 0 ? (
<p className="text-gray-500">{dict.publicMenu.noEntry}</p>
) : (
items.map((e) => {
const codes = dishCodes(e.dish);
return (
<p key={e.id} className="mb-1 last:mb-0">
<span className="font-semibold">
{localized(e.dish, locale)}
</span>
{showLabeling && codes && (
<sup className="dish-codes">{codes}</sup>
)}{" "}
– {formatPrice(e.dish.price)}
</p>
);
})
)}
</div>
</div> </div>
<div className="bg-[var(--c-row-bg)] px-3 py-2 text-sm"> );
{items.length === 0 ? ( })}
<p className="text-gray-500">{dict.publicMenu.noEntry}</p> </div>
) : ( )}
items.map((e) => {
const codes = dishCodes(e.dish);
return (
<p key={e.id} className="mb-1 last:mb-0">
<span className="font-semibold">
{localized(e.dish, locale)}
</span>
{showLabeling && codes && (
<sup className="dish-codes">{codes}</sup>
)}{" "}
– {formatPrice(e.dish.price)}
</p>
);
})
)}
</div>
</div>
);
})}
</div>
</div> </div>
); );
} }
+274 -25
View File
@@ -14,12 +14,19 @@ import {
} from "@/lib/menu"; } from "@/lib/menu";
import { formatMessage } from "@/lib/i18n/shared"; import { formatMessage } from "@/lib/i18n/shared";
import { localized } from "@/lib/types"; import { localized } from "@/lib/types";
import type { Dish, MenuEntry, Section } from "@/lib/types"; import type { ClosedDay, Dish, MenuEntry, Section } from "@/lib/types";
import { useDict, useLocale } from "@/components/i18n/LocaleProvider"; import { useDict, useLanguages, useLocale } from "@/components/i18n/LocaleProvider";
export default function WeekEditor({ monday }: { monday: string }) { export default function WeekEditor({
monday,
translateEnabled,
}: {
monday: string;
translateEnabled: boolean;
}) {
const dict = useDict(); const dict = useDict();
const locale = useLocale(); const locale = useLocale();
const languages = useLanguages().filter((l) => l.code !== "de");
const friday = addDays(monday, 4); const friday = addDays(monday, 4);
const days = dict.weekdays.long.map((weekday, i) => ({ const days = dict.weekdays.long.map((weekday, i) => ({
weekday, weekday,
@@ -32,6 +39,7 @@ export default function WeekEditor({ monday }: { monday: string }) {
const [sections, setSections] = useState<Section[]>([]); const [sections, setSections] = useState<Section[]>([]);
const [dishes, setDishes] = useState<Dish[]>([]); const [dishes, setDishes] = useState<Dish[]>([]);
const [entries, setEntries] = useState<MenuEntry[]>([]); const [entries, setEntries] = useState<MenuEntry[]>([]);
const [closedDays, setClosedDays] = useState<ClosedDay[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// Zelle, in der gerade ein Gericht hinzugefügt wird // Zelle, in der gerade ein Gericht hinzugefügt wird
@@ -43,6 +51,19 @@ export default function WeekEditor({ monday }: { monday: string }) {
// Mobil: ein Tag auf einmal statt aller fünf gleichzeitig // Mobil: ein Tag auf einmal statt aller fünf gleichzeitig
const [selectedDayIdx, setSelectedDayIdx] = useState(todayIdx >= 0 ? todayIdx : 0); const [selectedDayIdx, setSelectedDayIdx] = useState(todayIdx >= 0 ? todayIdx : 0);
// Tag, für den gerade das Schließen-Formular offen ist
const [closingIso, setClosingIso] = useState<string | null>(null);
const [closeReason, setCloseReason] = useState("");
const [closeTranslations, setCloseTranslations] = useState<Record<string, string>>({});
const [closeTranslateState, setCloseTranslateState] = useState<"idle" | "busy" | "done">(
"idle"
);
const [closeBusy, setCloseBusy] = useState(false);
function closedDayFor(iso: string): ClosedDay | undefined {
return closedDays.find((c) => c.closed_date === iso);
}
function startAdding(iso: string, section: string) { function startAdding(iso: string, section: string) {
setAdding({ iso, section }); setAdding({ iso, section });
setSelectedDish(""); setSelectedDish("");
@@ -57,17 +78,19 @@ export default function WeekEditor({ monday }: { monday: string }) {
const load = useCallback(async () => { const load = useCallback(async () => {
setError(null); setError(null);
const [se, di, en] = await Promise.all([ const [se, di, en, cd] = await Promise.all([
fetch("/api/sections"), fetch("/api/sections"),
fetch("/api/dishes"), fetch("/api/dishes"),
fetch(`/api/menu-entries?from=${monday}&to=${friday}`), fetch(`/api/menu-entries?from=${monday}&to=${friday}`),
fetch(`/api/closed-days?from=${monday}&to=${friday}`),
]); ]);
if (!se.ok || !di.ok || !en.ok) { if (!se.ok || !di.ok || !en.ok || !cd.ok) {
setError(formatMessage(dict.weekEditor.loadError, { message: "load failed" })); setError(formatMessage(dict.weekEditor.loadError, { message: "load failed" }));
} else { } else {
setSections(await se.json()); setSections(await se.json());
setDishes(await di.json()); setDishes(await di.json());
setEntries(await en.json()); setEntries(await en.json());
setClosedDays(await cd.json());
} }
setLoading(false); setLoading(false);
}, [monday, friday, dict]); }, [monday, friday, dict]);
@@ -107,6 +130,77 @@ export default function WeekEditor({ monday }: { monday: string }) {
await load(); await load();
} }
function startClosing(iso: string) {
const existing = closedDayFor(iso);
setClosingIso(iso);
setCloseReason(existing?.reason ?? "");
setCloseTranslations({ ...(existing?.translations ?? {}) });
setCloseTranslateState("idle");
}
function cancelClosing() {
setClosingIso(null);
}
async function saveClosing() {
if (!closingIso) return;
setCloseBusy(true);
setError(null);
const res = await fetch("/api/closed-days", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
closed_date: closingIso,
reason: closeReason.trim() || null,
translations: closeTranslations,
}),
});
setCloseBusy(false);
if (!res.ok) {
setError(formatMessage(dict.weekEditor.saveError, { message: String(res.status) }));
return;
}
setClosingIso(null);
await load();
}
async function reopenDay(iso: string) {
setCloseBusy(true);
setError(null);
const res = await fetch(`/api/closed-days/${iso}`, { method: "DELETE" });
setCloseBusy(false);
if (!res.ok) {
setError(formatMessage(dict.weekEditor.deleteError, { message: String(res.status) }));
return;
}
if (closingIso === iso) setClosingIso(null);
await load();
}
async function autoTranslateClosing() {
if (!closingIso || !closeReason.trim()) return;
setCloseTranslateState("busy");
setError(null);
try {
const res = await fetch("/api/translate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
table: "closed_days",
id: closedDayFor(closingIso) ? closingIso : null,
text: closeReason.trim(),
}),
});
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? `Fehler (${res.status})`);
setCloseTranslations((prev) => ({ ...prev, ...(data.translations ?? {}) }));
setCloseTranslateState("done");
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
setCloseTranslateState("idle");
}
}
const [hintBefore, hintAfter] = dict.weekEditor.newDishesHint.split("{link}"); const [hintBefore, hintAfter] = dict.weekEditor.newDishesHint.split("{link}");
const selectedDishObj = dishes.find((d) => d.id === selectedDish) ?? null; const selectedDishObj = dishes.find((d) => d.id === selectedDish) ?? null;
const query = dishSearch.trim().toLowerCase(); const query = dishSearch.trim().toLowerCase();
@@ -114,6 +208,132 @@ export default function WeekEditor({ monday }: { monday: string }) {
? dishes.filter((d) => localized(d, locale).toLowerCase().includes(query)) ? dishes.filter((d) => localized(d, locale).toLowerCase().includes(query))
: dishes; : dishes;
// Kleines Schloss-Symbol neben dem Tagesdatum: 🔓 zum Schließen
// anklicken, 🔒 zeigt einen geschlossenen Tag und öffnet ihn per Klick
// wieder – der Zustand des Symbols selbst ist die Beschriftung.
function renderClosureToggle(iso: string) {
const closed = Boolean(closedDayFor(iso));
return (
<button
type="button"
className="mt-0.5 text-base leading-none align-middle opacity-70 hover:opacity-100 disabled:opacity-40"
onClick={() => (closed ? reopenDay(iso) : startClosing(iso))}
disabled={closeBusy}
title={closed ? dict.weekEditor.reopenDay : dict.weekEditor.closeDay}
aria-label={closed ? dict.weekEditor.reopenDay : dict.weekEditor.closeDay}
>
{closed ? "🔒" : "🔓"}
</button>
);
}
// Banner, das bei einem geschlossenen Tag alle Essenszeiten-Zellen
// dieses Tages ersetzt (Desktop: eine über rowSpan verbundene Zelle,
// Mobil: anstelle der Essenszeiten-Karten) – "Bearbeiten" öffnet das
// Formular erneut, um nur den Grund anzupassen, ohne den Tag über das
// Symbol im Header erst wieder öffnen zu müssen.
function renderClosedBanner(iso: string, closed: ClosedDay) {
return (
<div className="flex flex-col items-center justify-center gap-1 py-4 text-red-700">
<span className="text-2xl" aria-hidden>
🔒
</span>
<span className="font-semibold">{dict.weekEditor.closedLabel}</span>
{closed.reason && <span className="text-sm px-2 text-center">{closed.reason}</span>}
<button
type="button"
className="mt-1 text-xs text-gray-500 hover:text-[var(--c-primary)] underline"
onClick={() => startClosing(iso)}
>
{dict.common.edit}
</button>
</div>
);
}
// Formular zum Schließen/Bearbeiten eines Tages – ein gemeinsames
// Formular für Desktop-Tabelle und mobile Tagesansicht, da immer nur
// ein Tag gleichzeitig bearbeitet werden kann.
function renderClosingForm() {
if (!closingIso) return null;
const day = days.find((d) => d.iso === closingIso);
return (
<form
onSubmit={(e) => {
e.preventDefault();
saveClosing();
}}
className="card mb-4 space-y-3"
>
<h2 className="font-semibold">
{dict.weekEditor.closeDay}: {day?.weekday} ·{" "}
{formatDateLong(closingIso, dict.dateLocale)}
</h2>
<div>
<label className="label" htmlFor="closed-reason">
{dict.weekEditor.closedReasonLabel}
</label>
<input
id="closed-reason"
className="input"
placeholder={dict.weekEditor.closedReasonPlaceholder}
value={closeReason}
onChange={(e) => setCloseReason(e.target.value)}
/>
</div>
{languages.length > 0 && (
<div className="grid gap-3 sm:grid-cols-2">
{languages.map((lang) => (
<div key={lang.code}>
<label className="label" htmlFor={`closed-reason-${lang.code}`}>
{lang.label}
</label>
<input
id={`closed-reason-${lang.code}`}
className="input"
value={closeTranslations[lang.code] ?? ""}
onChange={(e) =>
setCloseTranslations({
...closeTranslations,
[lang.code]: e.target.value,
})
}
/>
</div>
))}
</div>
)}
{translateEnabled && languages.length > 0 && (
<div>
<button
type="button"
className="btn btn-neutral"
onClick={autoTranslateClosing}
disabled={closeTranslateState === "busy" || !closeReason.trim()}
>
{closeTranslateState === "busy"
? dict.dishManager.translating
: dict.dishManager.autoTranslate}
</button>
{closeTranslateState === "done" && (
<span className="ml-2 text-sm text-green-700">
{dict.dishManager.translated}
</span>
)}
</div>
)}
<div className="flex gap-2">
<button type="submit" className="btn btn-add" disabled={closeBusy}>
{dict.common.save}
</button>
<button type="button" className="btn btn-neutral" onClick={cancelClosing}>
{dict.common.cancel}
</button>
</div>
</form>
);
}
// Inhalt einer Tag/Essenszeit-Zelle: Eintragsliste + "hinzufügen"-Flow. // Inhalt einer Tag/Essenszeit-Zelle: Eintragsliste + "hinzufügen"-Flow.
// Identisches Fragment für Desktop-Tabelle und Mobile-Karten, damit der // Identisches Fragment für Desktop-Tabelle und Mobile-Karten, damit der
// Bearbeitungs-Zustand (adding/selectedDish/dishSearch) nicht dupliziert // Bearbeitungs-Zustand (adding/selectedDish/dishSearch) nicht dupliziert
@@ -250,6 +470,7 @@ export default function WeekEditor({ monday }: { monday: string }) {
{error} {error}
</p> </p>
)} )}
{renderClosingForm()}
{loading ? ( {loading ? (
<p className="text-gray-500">{dict.weekEditor.loading}</p> <p className="text-gray-500">{dict.weekEditor.loading}</p>
) : ( ) : (
@@ -272,22 +493,35 @@ export default function WeekEditor({ monday }: { monday: string }) {
</button> </button>
))} ))}
</div> </div>
<h2 className="font-bold text-[var(--c-primary)] mb-2"> <h2 className="font-bold text-[var(--c-primary)] mb-1">
{selectedDay.weekday} · {formatDateLong(selectedDay.iso, dict.dateLocale)} {selectedDay.weekday} · {formatDateLong(selectedDay.iso, dict.dateLocale)}{" "}
{renderClosureToggle(selectedDay.iso)}
</h2> </h2>
<div className="space-y-3"> {(() => {
{sections.map((s) => ( const closed = closedDayFor(selectedDay.iso);
<div key={s.key} className="rounded-md border border-gray-200 overflow-hidden"> if (closed) {
<div className="bg-[var(--c-primary)] text-[var(--c-header-text)] px-3 py-1.5 text-sm"> return (
<span className="font-semibold">{localized(s, locale)}</span>{" "} <div className="rounded-md border border-red-200 bg-red-50">
<span className="opacity-80">· {s.time_range}</span> {renderClosedBanner(selectedDay.iso, closed)}
</div>
<div className="bg-[var(--c-row-bg)] px-3 py-2 text-sm text-center">
{renderCell(selectedDay.iso, s.key)}
</div> </div>
);
}
return (
<div className="space-y-3">
{sections.map((s) => (
<div key={s.key} className="rounded-md border border-gray-200 overflow-hidden">
<div className="bg-[var(--c-primary)] text-[var(--c-header-text)] px-3 py-1.5 text-sm">
<span className="font-semibold">{localized(s, locale)}</span>{" "}
<span className="opacity-80">· {s.time_range}</span>
</div>
<div className="bg-[var(--c-row-bg)] px-3 py-2 text-sm text-center">
{renderCell(selectedDay.iso, s.key)}
</div>
</div>
))}
</div> </div>
))} );
</div> })()}
</div> </div>
{/* Ab "sm": klassische Wochentabelle */} {/* Ab "sm": klassische Wochentabelle */}
@@ -300,23 +534,38 @@ export default function WeekEditor({ monday }: { monday: string }) {
<th key={d.iso} className="day-head"> <th key={d.iso} className="day-head">
<span className="font-bold">{d.weekday}</span> <span className="font-bold">{d.weekday}</span>
<br /> <br />
{formatDateLong(d.iso, dict.dateLocale)} {formatDateLong(d.iso, dict.dateLocale)} {renderClosureToggle(d.iso)}
</th> </th>
))} ))}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{sections.map((s) => ( {sections.map((s, sIdx) => (
<tr key={s.key}> <tr key={s.key}>
<th className="section-head"> <th className="section-head">
<div>{localized(s, locale)}</div> <div>{localized(s, locale)}</div>
<div className="time">{s.time_range}</div> <div className="time">{s.time_range}</div>
</th> </th>
{days.map((d) => ( {days.map((d) => {
<td key={d.iso} className="cell"> const closed = closedDayFor(d.iso);
{renderCell(d.iso, s.key)} if (closed) {
</td> if (sIdx > 0) return null;
))} return (
<td
key={d.iso}
className="cell closed-cell"
rowSpan={sections.length}
>
{renderClosedBanner(d.iso, closed)}
</td>
);
}
return (
<td key={d.iso} className="cell">
{renderCell(d.iso, s.key)}
</td>
);
})}
</tr> </tr>
))} ))}
</tbody> </tbody>
+18
View File
@@ -0,0 +1,18 @@
-- Kantine an einzelnen Tagen als geschlossen markieren (Feiertage o. Ä.),
-- mit optionalem, mehrsprachigem Grund. Ein Tag ohne Zeile hier gilt als
-- regulär geöffnet.
create table closed_days (
closed_date date primary key,
reason text,
created_at timestamptz not null default now()
);
-- Wert-Spalte heißt bewusst "name" (nicht "reason"), damit die generische
-- Zugriffsschicht in lib/db/translations.ts unverändert funktioniert –
-- dieselbe Konvention wie bei section_translations.name für sections.label.
create table closed_day_translations (
closed_date date not null references closed_days(closed_date) on delete cascade,
language_code text not null references languages(code) on delete cascade,
name text not null,
primary key (closed_date, language_code)
);
+50
View File
@@ -0,0 +1,50 @@
import "server-only";
import { query, queryOne } from "@/lib/db";
import { loadTranslationsFor, replaceTranslations } from "./translations";
import type { ClosedDay } from "@/lib/types";
interface ClosedDayRow {
closed_date: string;
reason: string | null;
}
/** Geschlossene Tage in einem Zeitraum (für Wochenplan/öffentliche Speisekarte/RSS). */
export async function listClosedDays(fromISO: string, toISO: string): Promise<ClosedDay[]> {
const rows = await query<ClosedDayRow>(
"select closed_date, reason from closed_days where closed_date between $1 and $2",
[fromISO, toISO]
);
if (rows.length === 0) return [];
const translations = await loadTranslationsFor(
"closed_day_translations",
rows.map((r) => r.closed_date)
);
return rows.map((r) => ({
...r,
translations: translations.get(r.closed_date) ?? {},
}));
}
/** Tag als geschlossen markieren (oder bestehenden Grund aktualisieren). */
export async function setClosedDay(
date: string,
reason: string | null,
translations?: Record<string, string | null>
): Promise<ClosedDay> {
const row = (await queryOne<ClosedDayRow>(
`insert into closed_days (closed_date, reason) values ($1, $2)
on conflict (closed_date) do update set reason = excluded.reason
returning closed_date, reason`,
[date, reason]
))!;
if (translations) {
await replaceTranslations("closed_day_translations", row.closed_date, translations);
}
const loaded = await loadTranslationsFor("closed_day_translations", [row.closed_date]);
return { ...row, translations: loaded.get(row.closed_date) ?? {} };
}
/** Tag wieder öffnen (löscht auch alle Übersetzungen des Grunds per Cascade). */
export async function removeClosedDay(date: string): Promise<void> {
await query("delete from closed_days where closed_date = $1", [date]);
}
+3 -1
View File
@@ -11,13 +11,15 @@ export type TranslationTable =
| "dish_translations" | "dish_translations"
| "section_translations" | "section_translations"
| "allergen_translations" | "allergen_translations"
| "additive_translations"; | "additive_translations"
| "closed_day_translations";
const ID_COLUMN: Record<TranslationTable, string> = { const ID_COLUMN: Record<TranslationTable, string> = {
dish_translations: "dish_id", dish_translations: "dish_id",
section_translations: "section_key", section_translations: "section_key",
allergen_translations: "allergen_code", allergen_translations: "allergen_code",
additive_translations: "additive_code", additive_translations: "additive_code",
closed_day_translations: "closed_date",
}; };
/** Alle Übersetzungen einer Tabelle, gruppiert nach Eltern-ID. */ /** Alle Übersetzungen einer Tabelle, gruppiert nach Eltern-ID. */
+6
View File
@@ -75,6 +75,7 @@ const de = {
suggestDish: "Rezept vorschlagen", suggestDish: "Rezept vorschlagen",
noEntry: "–", noEntry: "–",
rssFeed: "RSS-Feed abonnieren", rssFeed: "RSS-Feed abonnieren",
closedLabel: "Geschlossen",
}, },
weekEditor: { weekEditor: {
title: "Wochenplan", title: "Wochenplan",
@@ -87,6 +88,11 @@ const de = {
duplicateEntry: "Dieses Gericht ist an diesem Tag bereits zugewiesen.", duplicateEntry: "Dieses Gericht ist an diesem Tag bereits zugewiesen.",
saveError: "Speichern fehlgeschlagen: {message}", saveError: "Speichern fehlgeschlagen: {message}",
deleteError: "Löschen fehlgeschlagen: {message}", deleteError: "Löschen fehlgeschlagen: {message}",
closeDay: "Tag schließen",
reopenDay: "Wieder öffnen",
closedLabel: "Geschlossen",
closedReasonLabel: "Grund (optional)",
closedReasonPlaceholder: "z. B. Weihnachtsfeiertage",
}, },
dishManager: { dishManager: {
title: "Gerichte", title: "Gerichte",
+5 -2
View File
@@ -14,12 +14,15 @@ export * from "./shared";
/** Aktuelle Sprache aus dem Cookie lesen, validiert gegen die aktiven /** Aktuelle Sprache aus dem Cookie lesen, validiert gegen die aktiven
* Sprachen (Server-Komponenten). Ungültig oder nicht mehr aktiv → * Sprachen (Server-Komponenten). Ungültig oder nicht mehr aktiv →
* Basis-Sprache. */ * Basis-Sprache, sofern die noch aktiv ist – sonst die erste aktive
* Sprache (z. B. wenn Deutsch im Admin-Bereich deaktiviert wurde). */
export async function getLocale(): Promise<Locale> { export async function getLocale(): Promise<Locale> {
const store = await cookies(); const store = await cookies();
const value = store.get(LOCALE_COOKIE)?.value; const value = store.get(LOCALE_COOKIE)?.value;
const active = await listActiveLanguageCodes(); const active = await listActiveLanguageCodes();
return isLocale(value, active) ? value : DEFAULT_LOCALE; if (isLocale(value, active)) return value;
if (active.includes(DEFAULT_LOCALE)) return DEFAULT_LOCALE;
return active[0] ?? DEFAULT_LOCALE;
} }
/** Wörterbuch für eine Sprache. Nicht-deutsche Sprachen werden aus der /** Wörterbuch für eine Sprache. Nicht-deutsche Sprachen werden aus der
+10 -1
View File
@@ -50,11 +50,20 @@ export interface DishSuggestion {
created_at: string; created_at: string;
} }
export interface ClosedDay {
closed_date: string;
reason: string | null;
translations: Record<string, string>;
}
/** Übersetzten Namen für die aktuelle Sprache liefern, sonst Deutsch als Fallback. */ /** Übersetzten Namen für die aktuelle Sprache liefern, sonst Deutsch als Fallback. */
export function localized( export function localized(
item: { item: {
name?: string; name?: string;
label?: string; label?: string;
// Grund eines geschlossenen Tages (s. ClosedDay) – optional, anders
// als name/label bei den übrigen Inhaltstypen.
reason?: string | null;
// Neue, dynamische Form (Gerichte/Essenszeiten/Allergene/ // Neue, dynamische Form (Gerichte/Essenszeiten/Allergene/
// Zusatzstoffe, s. lib/db/translations.ts) – Sprachcode -> Text. // Zusatzstoffe, s. lib/db/translations.ts) – Sprachcode -> Text.
translations?: Record<string, string>; translations?: Record<string, string>;
@@ -68,7 +77,7 @@ export function localized(
}, },
locale: string locale: string
): string { ): string {
const de = item.name ?? item.label ?? ""; const de = item.name ?? item.label ?? item.reason ?? "";
if (locale === "de") return de; if (locale === "de") return de;
if (item.translations) return item.translations[locale] || de; if (item.translations) return item.translations[locale] || de;
if (locale === "en") return item.name_en || item.label_en || de; if (locale === "en") return item.name_en || item.label_en || de;
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "canteen-menu", "name": "canteen-menu",
"version": "1.11.0", "version": "1.12.1",
"private": true, "private": true,
"scripts": { "scripts": {
"predev": "node --env-file-if-exists=.env.local db/migrate.mjs && node --env-file-if-exists=.env.local scripts/bootstrap-admin.mjs", "predev": "node --env-file-if-exists=.env.local db/migrate.mjs && node --env-file-if-exists=.env.local scripts/bootstrap-admin.mjs",