diff --git a/CHANGELOG.md b/CHANGELOG.md index 11181f7..a658e51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,26 @@ 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.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 diff --git a/app/api/closed-days/[date]/route.ts b/app/api/closed-days/[date]/route.ts new file mode 100644 index 0000000..dba83f0 --- /dev/null +++ b/app/api/closed-days/[date]/route.ts @@ -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; + } +} diff --git a/app/api/closed-days/route.ts b/app/api/closed-days/route.ts new file mode 100644 index 0000000..264c11b --- /dev/null +++ b/app/api/closed-days/route.ts @@ -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; + }; + 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; + } +} diff --git a/app/api/translate/route.ts b/app/api/translate/route.ts index 8440d6c..fbcafba 100644 --- a/app/api/translate/route.ts +++ b/app/api/translate/route.ts @@ -14,6 +14,7 @@ const ALLOWED_TABLES: Record DeepL target_lang. Sprachen ohne Eintrag hier sind diff --git a/app/feed.xml/route.ts b/app/feed.xml/route.ts index 872a7d8..1ed7539 100644 --- a/app/feed.xml/route.ts +++ b/app/feed.xml/route.ts @@ -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
("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) => - `
  • ${escapeXml(localized(e.dish, "de"))} – ${formatPrice(e.dish.price)}
  • ` - ) + const closed = closedDays.find((c) => c.closed_date === iso); + const dayContent = closed + ? `

    Geschlossen${ + closed.reason ? ` – ${escapeXml(closed.reason)}` : "" + }

    ` + : sections + .map((s) => { + const dishes = entriesFor(entries, iso, s.key); + if (dishes.length === 0) return ""; + const list = dishes + .map( + (e) => + `
  • ${escapeXml(localized(e.dish, "de"))} – ${formatPrice(e.dish.price)}
  • ` + ) + .join(""); + return `

    ${escapeXml(localized(s, "de"))} (${escapeXml( + s.time_range + )})

      ${list}
    `; + }) .join(""); - return `

    ${escapeXml(localized(s, "de"))} (${escapeXml( - s.time_range - )})

      ${list}
    `; - }) - .join(""); if (!dayContent) return ""; const [y, m, d] = iso.split("-").map(Number); diff --git a/app/intern/page.tsx b/app/intern/page.tsx index abb45bc..8f93148 100644 --- a/app/intern/page.tsx +++ b/app/intern/page.tsx @@ -7,5 +7,10 @@ export default async function InternPage({ searchParams: Promise<{ woche?: string }>; }) { const { woche } = await searchParams; - return ; + return ( + + ); } diff --git a/app/page.tsx b/app/page.tsx index b83be49..8c03d5d 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -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>("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({ - {days.map((d) => ( - - {d.weekday} -
    - {formatDateLong(d.iso, dict.dateLocale)} - - ))} + {days.map((d) => { + const closed = closedDayFor(d.iso); + return ( + + {d.weekday} +
    + {formatDateLong(d.iso, dict.dateLocale)} + {closed && ( +
    + + {dict.publicMenu.closedLabel} + + {localized(closed, locale) && ( +
    + {localized(closed, locale)} +
    + )} +
    + )} + + ); + })} @@ -173,21 +193,23 @@ export default async function Home({ {days.map((d) => ( - {entriesFor(entries, d.iso, s.key).map((e) => { - const codes = dishCodes(e.dish); - return ( -
    - - {localized(e.dish, locale)} - - {labelingDetailed && codes && ( - {codes} - )} - {" – "} - {formatPrice(e.dish.price)} -
    - ); - })} + {closedDayFor(d.iso) + ? null + : entriesFor(entries, d.iso, s.key).map((e) => { + const codes = dishCodes(e.dish); + return ( +
    + + {localized(e.dish, locale)} + + {labelingDetailed && codes && ( + {codes} + )} + {" – "} + {formatPrice(e.dish.price)} +
    + ); + })} ))} diff --git a/components/PublicMobileMenu.tsx b/components/PublicMobileMenu.tsx index b0bd2aa..e118c4a 100644 --- a/components/PublicMobileMenu.tsx +++ b/components/PublicMobileMenu.tsx @@ -19,7 +19,7 @@ import { weekdayIndex, } from "@/lib/menu"; 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 LanguageSwitcher from "@/components/i18n/LanguageSwitcher"; @@ -27,11 +27,13 @@ export default function PublicMobileMenu({ monday, sections, entries, + closedDays, showLabeling, }: { monday: string; sections: Section[]; entries: MenuEntry[]; + closedDays: ClosedDay[]; showLabeling: boolean; }) { const router = useRouter(); @@ -60,47 +62,55 @@ export default function PublicMobileMenu({ } function renderDay(day: { weekday: string; iso: string }) { + const closed = closedDays.find((c) => c.closed_date === day.iso); return (

    {day.weekday} · {formatDateLong(day.iso, dict.dateLocale)}

    -
    - {sections.map((s) => { - const items = entriesFor(entries, day.iso, s.key); - return ( -
    -
    - {localized(s, locale)}{" "} - · {s.time_range} + {closed ? ( +
    + {dict.publicMenu.closedLabel} + {localized(closed, locale) && – {localized(closed, locale)}} +
    + ) : ( +
    + {sections.map((s) => { + const items = entriesFor(entries, day.iso, s.key); + return ( +
    +
    + {localized(s, locale)}{" "} + · {s.time_range} +
    +
    + {items.length === 0 ? ( +

    {dict.publicMenu.noEntry}

    + ) : ( + items.map((e) => { + const codes = dishCodes(e.dish); + return ( +

    + + {localized(e.dish, locale)} + + {showLabeling && codes && ( + {codes} + )}{" "} + – {formatPrice(e.dish.price)} +

    + ); + }) + )} +
    -
    - {items.length === 0 ? ( -

    {dict.publicMenu.noEntry}

    - ) : ( - items.map((e) => { - const codes = dishCodes(e.dish); - return ( -

    - - {localized(e.dish, locale)} - - {showLabeling && codes && ( - {codes} - )}{" "} - – {formatPrice(e.dish.price)} -

    - ); - }) - )} -
    -
    - ); - })} -
    + ); + })} +
    + )}
    ); } diff --git a/components/WeekEditor.tsx b/components/WeekEditor.tsx index 06c525d..bbfa47c 100644 --- a/components/WeekEditor.tsx +++ b/components/WeekEditor.tsx @@ -14,12 +14,19 @@ import { } from "@/lib/menu"; import { formatMessage } from "@/lib/i18n/shared"; import { localized } from "@/lib/types"; -import type { Dish, MenuEntry, Section } from "@/lib/types"; -import { useDict, useLocale } from "@/components/i18n/LocaleProvider"; +import type { ClosedDay, Dish, MenuEntry, Section } from "@/lib/types"; +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 locale = useLocale(); + const languages = useLanguages().filter((l) => l.code !== "de"); const friday = addDays(monday, 4); const days = dict.weekdays.long.map((weekday, i) => ({ weekday, @@ -32,6 +39,7 @@ export default function WeekEditor({ monday }: { monday: string }) { const [sections, setSections] = useState([]); const [dishes, setDishes] = useState([]); const [entries, setEntries] = useState([]); + const [closedDays, setClosedDays] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); // 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 const [selectedDayIdx, setSelectedDayIdx] = useState(todayIdx >= 0 ? todayIdx : 0); + // Tag, für den gerade das Schließen-Formular offen ist + const [closingIso, setClosingIso] = useState(null); + const [closeReason, setCloseReason] = useState(""); + const [closeTranslations, setCloseTranslations] = useState>({}); + 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) { setAdding({ iso, section }); setSelectedDish(""); @@ -57,17 +78,19 @@ export default function WeekEditor({ monday }: { monday: string }) { const load = useCallback(async () => { setError(null); - const [se, di, en] = await Promise.all([ + const [se, di, en, cd] = await Promise.all([ fetch("/api/sections"), fetch("/api/dishes"), 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" })); } else { setSections(await se.json()); setDishes(await di.json()); setEntries(await en.json()); + setClosedDays(await cd.json()); } setLoading(false); }, [monday, friday, dict]); @@ -107,6 +130,77 @@ export default function WeekEditor({ monday }: { monday: string }) { 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 selectedDishObj = dishes.find((d) => d.id === selectedDish) ?? null; const query = dishSearch.trim().toLowerCase(); @@ -114,6 +208,121 @@ export default function WeekEditor({ monday }: { monday: string }) { ? dishes.filter((d) => localized(d, locale).toLowerCase().includes(query)) : dishes; + // Kleine Anzeige/Umschaltung unter dem Tagesdatum: "Geschlossen" + + // Wieder-öffnen-Button, sonst ein dezenter "Tag schließen"-Link. + function renderClosureBadge(iso: string) { + const closed = closedDayFor(iso); + if (closed) { + return ( +
    + + {dict.weekEditor.closedLabel} + + +
    + ); + } + return ( + + ); + } + + // 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 ( +
    { + e.preventDefault(); + saveClosing(); + }} + className="card mb-4 space-y-3" + > +

    + {dict.weekEditor.closeDay}: {day?.weekday} ·{" "} + {formatDateLong(closingIso, dict.dateLocale)} +

    +
    + + setCloseReason(e.target.value)} + /> +
    + {languages.length > 0 && ( +
    + {languages.map((lang) => ( +
    + + + setCloseTranslations({ + ...closeTranslations, + [lang.code]: e.target.value, + }) + } + /> +
    + ))} +
    + )} + {translateEnabled && languages.length > 0 && ( +
    + + {closeTranslateState === "done" && ( + + {dict.dishManager.translated} + + )} +
    + )} +
    + + +
    +
    + ); + } + // Inhalt einer Tag/Essenszeit-Zelle: Eintragsliste + "hinzufügen"-Flow. // Identisches Fragment für Desktop-Tabelle und Mobile-Karten, damit der // Bearbeitungs-Zustand (adding/selectedDish/dishSearch) nicht dupliziert @@ -121,6 +330,7 @@ export default function WeekEditor({ monday }: { monday: string }) { // unabhängig davon, welche Ansicht gerade rendert. function renderCell(iso: string, sectionKey: string) { const isAdding = adding?.iso === iso && adding?.section === sectionKey; + const closed = Boolean(closedDayFor(iso)); return ( <> {entriesFor(entries, iso, sectionKey).map((e) => { @@ -143,7 +353,7 @@ export default function WeekEditor({ monday }: { monday: string }) {
    ); })} - {isAdding ? ( + {closed ? null : isAdding ? (
    {selectedDishObj ? (
    @@ -250,6 +460,7 @@ export default function WeekEditor({ monday }: { monday: string }) { {error}

    )} + {renderClosingForm()} {loading ? (

    {dict.weekEditor.loading}

    ) : ( @@ -272,9 +483,10 @@ export default function WeekEditor({ monday }: { monday: string }) { ))}
    -

    +

    {selectedDay.weekday} · {formatDateLong(selectedDay.iso, dict.dateLocale)}

    +
    {renderClosureBadge(selectedDay.iso)}
    {sections.map((s) => (
    @@ -301,6 +513,7 @@ export default function WeekEditor({ monday }: { monday: string }) { {d.weekday}
    {formatDateLong(d.iso, dict.dateLocale)} + {renderClosureBadge(d.iso)} ))} diff --git a/db/migrations/0007_closed_days.sql b/db/migrations/0007_closed_days.sql new file mode 100644 index 0000000..562170c --- /dev/null +++ b/db/migrations/0007_closed_days.sql @@ -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) +); diff --git a/lib/db/closed-days.ts b/lib/db/closed-days.ts new file mode 100644 index 0000000..0e32814 --- /dev/null +++ b/lib/db/closed-days.ts @@ -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 { + const rows = await query( + "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 +): Promise { + const row = (await queryOne( + `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 { + await query("delete from closed_days where closed_date = $1", [date]); +} diff --git a/lib/db/translations.ts b/lib/db/translations.ts index 145893b..9571462 100644 --- a/lib/db/translations.ts +++ b/lib/db/translations.ts @@ -11,13 +11,15 @@ export type TranslationTable = | "dish_translations" | "section_translations" | "allergen_translations" - | "additive_translations"; + | "additive_translations" + | "closed_day_translations"; const ID_COLUMN: Record = { dish_translations: "dish_id", section_translations: "section_key", allergen_translations: "allergen_code", additive_translations: "additive_code", + closed_day_translations: "closed_date", }; /** Alle Übersetzungen einer Tabelle, gruppiert nach Eltern-ID. */ diff --git a/lib/i18n/dictionaries.ts b/lib/i18n/dictionaries.ts index 836ccb6..a4d0542 100644 --- a/lib/i18n/dictionaries.ts +++ b/lib/i18n/dictionaries.ts @@ -75,6 +75,7 @@ const de = { suggestDish: "Rezept vorschlagen", noEntry: "–", rssFeed: "RSS-Feed abonnieren", + closedLabel: "Geschlossen", }, weekEditor: { title: "Wochenplan", @@ -87,6 +88,11 @@ const de = { duplicateEntry: "Dieses Gericht ist an diesem Tag bereits zugewiesen.", saveError: "Speichern fehlgeschlagen: {message}", deleteError: "Löschen fehlgeschlagen: {message}", + closeDay: "Tag schließen", + reopenDay: "Wieder öffnen", + closedLabel: "Geschlossen", + closedReasonLabel: "Grund (optional)", + closedReasonPlaceholder: "z. B. Weihnachtsfeiertage", }, dishManager: { title: "Gerichte", diff --git a/lib/types.ts b/lib/types.ts index cbd21cf..6e96e3e 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -50,11 +50,20 @@ export interface DishSuggestion { created_at: string; } +export interface ClosedDay { + closed_date: string; + reason: string | null; + translations: Record; +} + /** Übersetzten Namen für die aktuelle Sprache liefern, sonst Deutsch als Fallback. */ export function localized( item: { name?: 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/ // Zusatzstoffe, s. lib/db/translations.ts) – Sprachcode -> Text. translations?: Record; @@ -68,7 +77,7 @@ export function localized( }, locale: string ): string { - const de = item.name ?? item.label ?? ""; + const de = item.name ?? item.label ?? item.reason ?? ""; if (locale === "de") return de; if (item.translations) return item.translations[locale] || de; if (locale === "en") return item.name_en || item.label_en || de; diff --git a/package.json b/package.json index 8454ea0..b09aae8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "canteen-menu", - "version": "1.11.1", + "version": "1.12.0", "private": true, "scripts": { "predev": "node --env-file-if-exists=.env.local db/migrate.mjs && node --env-file-if-exists=.env.local scripts/bootstrap-admin.mjs",