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.
This commit is contained in:
2026-07-20 12:35:00 +02:00
parent 0c05a16f09
commit 2a2c5affd5
15 changed files with 507 additions and 85 deletions
+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" },
allergens: { idColumn: "code", translationTable: "allergen_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
+25 -16
View File
@@ -1,5 +1,6 @@
import { query } from "@/lib/db";
import { loadMenuEntries } from "@/lib/db/menu-entries";
import { listClosedDays } from "@/lib/db/closed-days";
import { addDays, entriesFor, formatPrice, mondayOf, todayISO } from "@/lib/menu";
import { localized, SITE_TITLE } from "@/lib/types";
import type { Section } from "@/lib/types";
@@ -45,30 +46,38 @@ export async function GET() {
// Wochennavigation anzeigt.
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"),
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
.map((iso) => {
const dayContent = sections
.map((s) => {
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>`
)
const closed = closedDays.find((c) => c.closed_date === iso);
const dayContent = closed
? `<p><strong>Geschlossen</strong>${
closed.reason ? ` – ${escapeXml(closed.reason)}` : ""
}</p>`
: sections
.map((s) => {
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("");
return `<p><strong>${escapeXml(localized(s, "de"))} (${escapeXml(
s.time_range
)})</strong></p><ul>${list}</ul>`;
})
.join("");
if (!dayContent) return "";
const [y, m, d] = iso.split("-").map(Number);
+6 -1
View File
@@ -7,5 +7,10 @@ export default async function InternPage({
searchParams: Promise<{ woche?: string }>;
}) {
const { woche } = await searchParams;
return <WeekEditor monday={mondayOf(woche)} />;
return (
<WeekEditor
monday={mondayOf(woche)}
translateEnabled={!!process.env.DEEPL_API_KEY}
/>
);
}
+44 -22
View File
@@ -2,6 +2,7 @@ import Link from "next/link";
import { query } from "@/lib/db";
import { loadMenuEntries } from "@/lib/db/menu-entries";
import { loadTranslations } from "@/lib/db/translations";
import { listClosedDays } from "@/lib/db/closed-days";
import { getSettings } from "@/lib/db/settings";
import {
addDays,
@@ -43,6 +44,7 @@ export default async function Home({
sectionTranslations,
allergenTranslations,
additiveTranslations,
closedDays,
] = await Promise.all([
query<Omit<Section, "translations">>("select key, label, time_range, sort from sections order by sort"),
loadMenuEntries(monday, friday),
@@ -52,7 +54,9 @@ export default async function Home({
loadTranslations("section_translations"),
loadTranslations("allergen_translations"),
loadTranslations("additive_translations"),
listClosedDays(monday, friday),
]);
const closedDayFor = (iso: string) => closedDays.find((c) => c.closed_date === iso);
const sections: Section[] = sectionRows.map((s) => ({
...s,
@@ -130,6 +134,7 @@ export default async function Home({
monday={monday}
sections={sections}
entries={entries}
closedDays={closedDays}
showLabeling={labelingDetailed}
/>
@@ -155,13 +160,28 @@ export default async function Home({
<thead>
<tr>
<th className="section-head w-56" aria-hidden />
{days.map((d) => (
<th key={d.iso} className="day-head">
<span className="font-bold">{d.weekday}</span>
<br />
{formatDateLong(d.iso, dict.dateLocale)}
</th>
))}
{days.map((d) => {
const closed = closedDayFor(d.iso);
return (
<th key={d.iso} className="day-head">
<span className="font-bold">{d.weekday}</span>
<br />
{formatDateLong(d.iso, dict.dateLocale)}
{closed && (
<div className="mt-1 text-xs font-normal">
<span className="rounded-full bg-red-100 text-red-700 px-2 py-0.5 font-semibold">
{dict.publicMenu.closedLabel}
</span>
{localized(closed, locale) && (
<div className="mt-0.5 opacity-90">
{localized(closed, locale)}
</div>
)}
</div>
)}
</th>
);
})}
</tr>
</thead>
<tbody>
@@ -173,21 +193,23 @@ export default async function Home({
</th>
{days.map((d) => (
<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>
);
})}
{closedDayFor(d.iso)
? null
: 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>