Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9d4b51fce | ||
|
|
2a2c5affd5 | ||
|
|
0c05a16f09 | ||
|
|
c98a3617a4 | ||
|
|
ede11e5463 | ||
|
|
cd8b0fab9f |
@@ -4,6 +4,73 @@ 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.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
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
- Speiseplan-Inhalte variabel gemacht (Teil 2 des "mehrsprachig
|
||||||
|
variabel"-Umbaus): Gerichtnamen, Essenszeiten-Bezeichnungen sowie
|
||||||
|
Allergen-/Zusatzstoff-Namen sind jetzt genauso dynamisch mehrsprachig
|
||||||
|
wie die Bedienoberfläche aus Version 1.10.0, statt auf feste
|
||||||
|
Englisch-/Polnisch-Spalten begrenzt zu sein:
|
||||||
|
- In den Verwaltungsseiten (Gerichte, Essenszeiten, Allergene &
|
||||||
|
Zusatzstoffe) erscheint jetzt automatisch ein Eingabefeld je aktiv
|
||||||
|
geschalteter Sprache – wird im Sprachen-Admin eine neue Sprache
|
||||||
|
angelegt, taucht dort sofort ein passendes Feld auf, ohne
|
||||||
|
Codeänderung.
|
||||||
|
- Der "Automatisch übersetzen"-Button (DeepL) übersetzt künftig in
|
||||||
|
alle aktiven, von DeepL unterstützten Sprachen statt fix nur
|
||||||
|
Englisch/Polnisch.
|
||||||
|
- CSV-Export/-Import für Gerichte, Allergene und Zusatzstoffe hat
|
||||||
|
jetzt eine Spalte je aktiver Sprache (`name_en`, `name_fr`, …)
|
||||||
|
statt der bisher fest verdrahteten `name_en`/`name_pl`-Spalten.
|
||||||
|
- Reduziert der Admin das Sprachenfeature im Sprachen-Bereich auf nur
|
||||||
|
Deutsch, verschwinden diese Zusatzfelder ebenfalls automatisch aus
|
||||||
|
allen drei Verwaltungsseiten.
|
||||||
|
|
||||||
|
### Geändert
|
||||||
|
- Neue Migration `0006_content_translations.sql` ersetzt die festen
|
||||||
|
Spalten `name_en`/`name_pl` (Gerichte, Allergene, Zusatzstoffe) bzw.
|
||||||
|
`label_en`/`label_pl` (Essenszeiten) durch je eine
|
||||||
|
Übersetzungstabelle (`dish_translations`, `section_translations`,
|
||||||
|
`allergen_translations`, `additive_translations`); bestehende
|
||||||
|
Übersetzungen werden dabei automatisch übernommen.
|
||||||
|
**Vor dem Einspielen auf einem Produktivsystem unbedingt ein Backup
|
||||||
|
der Datenbank anlegen** – die alten Spalten werden nach der
|
||||||
|
Übernahme entfernt.
|
||||||
|
|
||||||
## [1.10.0] – 2026-07-20
|
## [1.10.0] – 2026-07-20
|
||||||
|
|
||||||
### Hinzugefügt
|
### Hinzugefügt
|
||||||
|
|||||||
@@ -1,14 +1,19 @@
|
|||||||
import { listCodes } from "@/lib/db/code-lists";
|
import { listCodes } from "@/lib/db/code-lists";
|
||||||
|
import { listActiveLanguageCodes } from "@/lib/db/languages";
|
||||||
import { authErrorResponse, requireRole } from "@/lib/auth/session";
|
import { authErrorResponse, requireRole } from "@/lib/auth/session";
|
||||||
import { toCsv } from "@/lib/csv";
|
import { toCsv } from "@/lib/csv";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
await requireRole(["staff", "admin"]);
|
await requireRole(["staff", "admin"]);
|
||||||
const items = await listCodes("additives");
|
const [items, activeCodes] = await Promise.all([
|
||||||
|
listCodes("additives"),
|
||||||
|
listActiveLanguageCodes(),
|
||||||
|
]);
|
||||||
|
const langCodes = activeCodes.filter((c) => c !== "de");
|
||||||
const rows = [
|
const rows = [
|
||||||
["code", "name", "name_en", "name_pl"],
|
["code", "name", ...langCodes.map((c) => `name_${c}`)],
|
||||||
...items.map((i) => [i.code, i.name, i.name_en ?? "", i.name_pl ?? ""]),
|
...items.map((i) => [i.code, i.name, ...langCodes.map((c) => i.translations[c] ?? "")]),
|
||||||
];
|
];
|
||||||
return new Response(toCsv(rows), {
|
return new Response(toCsv(rows), {
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { importCodes, type CodeImportRow } from "@/lib/db/code-lists";
|
import { importCodes, type CodeImportRow } from "@/lib/db/code-lists";
|
||||||
|
import { listActiveLanguageCodes } from "@/lib/db/languages";
|
||||||
import { authErrorResponse, requireRole } from "@/lib/auth/session";
|
import { authErrorResponse, requireRole } from "@/lib/auth/session";
|
||||||
import { isSameOrigin } from "@/lib/origin";
|
import { isSameOrigin } from "@/lib/origin";
|
||||||
import { parseCsvWithHeader } from "@/lib/csv";
|
import { parseCsvWithHeader } from "@/lib/csv";
|
||||||
@@ -22,8 +23,10 @@ export async function POST(request: Request) {
|
|||||||
if (codeIdx === -1 || nameIdx === -1) {
|
if (codeIdx === -1 || nameIdx === -1) {
|
||||||
return NextResponse.json({ error: "missing_columns" }, { status: 400 });
|
return NextResponse.json({ error: "missing_columns" }, { status: 400 });
|
||||||
}
|
}
|
||||||
const nameEnIdx = header.indexOf("name_en");
|
const activeCodes = (await listActiveLanguageCodes()).filter((c) => c !== "de");
|
||||||
const namePlIdx = header.indexOf("name_pl");
|
const langCols = activeCodes
|
||||||
|
.map((code) => ({ code, idx: header.indexOf(`name_${code}`) }))
|
||||||
|
.filter((c) => c.idx !== -1);
|
||||||
|
|
||||||
const rows: CodeImportRow[] = [];
|
const rows: CodeImportRow[] = [];
|
||||||
const skipped: { row: number; reason: string }[] = [];
|
const skipped: { row: number; reason: string }[] = [];
|
||||||
@@ -35,12 +38,12 @@ export async function POST(request: Request) {
|
|||||||
skipped.push({ row: rowNum, reason: "Kürzel oder Bezeichnung fehlt" });
|
skipped.push({ row: rowNum, reason: "Kürzel oder Bezeichnung fehlt" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
rows.push({
|
const translations: Record<string, string> = {};
|
||||||
code,
|
for (const { code: lang, idx } of langCols) {
|
||||||
name,
|
const value = c[idx]?.trim();
|
||||||
name_en: nameEnIdx >= 0 ? c[nameEnIdx]?.trim() || null : null,
|
if (value) translations[lang] = value;
|
||||||
name_pl: namePlIdx >= 0 ? c[namePlIdx]?.trim() || null : null,
|
}
|
||||||
});
|
rows.push({ code, name, translations });
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = rows.length > 0 ? await importCodes("additives", rows) : { created: 0, updated: 0 };
|
const result = rows.length > 0 ? await importCodes("additives", rows) : { created: 0, updated: 0 };
|
||||||
|
|||||||
@@ -1,14 +1,19 @@
|
|||||||
import { listCodes } from "@/lib/db/code-lists";
|
import { listCodes } from "@/lib/db/code-lists";
|
||||||
|
import { listActiveLanguageCodes } from "@/lib/db/languages";
|
||||||
import { authErrorResponse, requireRole } from "@/lib/auth/session";
|
import { authErrorResponse, requireRole } from "@/lib/auth/session";
|
||||||
import { toCsv } from "@/lib/csv";
|
import { toCsv } from "@/lib/csv";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
await requireRole(["staff", "admin"]);
|
await requireRole(["staff", "admin"]);
|
||||||
const items = await listCodes("allergens");
|
const [items, activeCodes] = await Promise.all([
|
||||||
|
listCodes("allergens"),
|
||||||
|
listActiveLanguageCodes(),
|
||||||
|
]);
|
||||||
|
const langCodes = activeCodes.filter((c) => c !== "de");
|
||||||
const rows = [
|
const rows = [
|
||||||
["code", "name", "name_en", "name_pl"],
|
["code", "name", ...langCodes.map((c) => `name_${c}`)],
|
||||||
...items.map((i) => [i.code, i.name, i.name_en ?? "", i.name_pl ?? ""]),
|
...items.map((i) => [i.code, i.name, ...langCodes.map((c) => i.translations[c] ?? "")]),
|
||||||
];
|
];
|
||||||
return new Response(toCsv(rows), {
|
return new Response(toCsv(rows), {
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { importCodes, type CodeImportRow } from "@/lib/db/code-lists";
|
import { importCodes, type CodeImportRow } from "@/lib/db/code-lists";
|
||||||
|
import { listActiveLanguageCodes } from "@/lib/db/languages";
|
||||||
import { authErrorResponse, requireRole } from "@/lib/auth/session";
|
import { authErrorResponse, requireRole } from "@/lib/auth/session";
|
||||||
import { isSameOrigin } from "@/lib/origin";
|
import { isSameOrigin } from "@/lib/origin";
|
||||||
import { parseCsvWithHeader } from "@/lib/csv";
|
import { parseCsvWithHeader } from "@/lib/csv";
|
||||||
@@ -22,8 +23,10 @@ export async function POST(request: Request) {
|
|||||||
if (codeIdx === -1 || nameIdx === -1) {
|
if (codeIdx === -1 || nameIdx === -1) {
|
||||||
return NextResponse.json({ error: "missing_columns" }, { status: 400 });
|
return NextResponse.json({ error: "missing_columns" }, { status: 400 });
|
||||||
}
|
}
|
||||||
const nameEnIdx = header.indexOf("name_en");
|
const activeCodes = (await listActiveLanguageCodes()).filter((c) => c !== "de");
|
||||||
const namePlIdx = header.indexOf("name_pl");
|
const langCols = activeCodes
|
||||||
|
.map((code) => ({ code, idx: header.indexOf(`name_${code}`) }))
|
||||||
|
.filter((c) => c.idx !== -1);
|
||||||
|
|
||||||
const rows: CodeImportRow[] = [];
|
const rows: CodeImportRow[] = [];
|
||||||
const skipped: { row: number; reason: string }[] = [];
|
const skipped: { row: number; reason: string }[] = [];
|
||||||
@@ -35,12 +38,12 @@ export async function POST(request: Request) {
|
|||||||
skipped.push({ row: rowNum, reason: "Kürzel oder Bezeichnung fehlt" });
|
skipped.push({ row: rowNum, reason: "Kürzel oder Bezeichnung fehlt" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
rows.push({
|
const translations: Record<string, string> = {};
|
||||||
code,
|
for (const { code: lang, idx } of langCols) {
|
||||||
name,
|
const value = c[idx]?.trim();
|
||||||
name_en: nameEnIdx >= 0 ? c[nameEnIdx]?.trim() || null : null,
|
if (value) translations[lang] = value;
|
||||||
name_pl: namePlIdx >= 0 ? c[namePlIdx]?.trim() || null : null,
|
}
|
||||||
});
|
rows.push({ code, name, translations });
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = rows.length > 0 ? await importCodes("allergens", rows) : { created: 0, updated: 0 };
|
const result = rows.length > 0 ? await importCodes("allergens", rows) : { created: 0, updated: 0 };
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,9 +1,18 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { query, queryOne } from "@/lib/db";
|
import { query, queryOne } from "@/lib/db";
|
||||||
|
import { loadTranslationsFor, replaceTranslations } from "@/lib/db/translations";
|
||||||
import { authErrorResponse, requireRole } from "@/lib/auth/session";
|
import { authErrorResponse, requireRole } from "@/lib/auth/session";
|
||||||
import { isSameOrigin } from "@/lib/origin";
|
import { isSameOrigin } from "@/lib/origin";
|
||||||
import type { Dish } from "@/lib/types";
|
import type { Dish } from "@/lib/types";
|
||||||
|
|
||||||
|
interface DishRow {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
price: string;
|
||||||
|
allergens: string[];
|
||||||
|
additives: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export async function PATCH(
|
export async function PATCH(
|
||||||
request: Request,
|
request: Request,
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
@@ -16,19 +25,26 @@ export async function PATCH(
|
|||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const body = (await request.json()) as {
|
const body = (await request.json()) as {
|
||||||
name: string;
|
name: string;
|
||||||
name_en: string | null;
|
|
||||||
name_pl: string | null;
|
|
||||||
price: number;
|
price: number;
|
||||||
allergens: string[];
|
allergens: string[];
|
||||||
additives: string[];
|
additives: string[];
|
||||||
|
translations?: Record<string, string | null>;
|
||||||
};
|
};
|
||||||
const dish = await queryOne<Dish>(
|
const row = await queryOne<DishRow>(
|
||||||
`update dishes set name = $1, name_en = $2, name_pl = $3, price = $4,
|
`update dishes set name = $1, price = $2, allergens = $3, additives = $4
|
||||||
allergens = $5, additives = $6
|
where id = $5 returning id, name, price, allergens, additives`,
|
||||||
where id = $7 returning *`,
|
[body.name, body.price, body.allergens, body.additives, id]
|
||||||
[body.name, body.name_en, body.name_pl, body.price, body.allergens, body.additives, id]
|
|
||||||
);
|
);
|
||||||
if (!dish) return NextResponse.json({ error: "not_found" }, { status: 404 });
|
if (!row) return NextResponse.json({ error: "not_found" }, { status: 404 });
|
||||||
|
if (body.translations) {
|
||||||
|
await replaceTranslations("dish_translations", row.id, body.translations);
|
||||||
|
}
|
||||||
|
const translations = await loadTranslationsFor("dish_translations", [row.id]);
|
||||||
|
const dish: Dish = {
|
||||||
|
...row,
|
||||||
|
price: Number(row.price),
|
||||||
|
translations: translations.get(row.id) ?? {},
|
||||||
|
};
|
||||||
return NextResponse.json(dish);
|
return NextResponse.json(dish);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const res = authErrorResponse(err);
|
const res = authErrorResponse(err);
|
||||||
|
|||||||
@@ -1,19 +1,35 @@
|
|||||||
import { query } from "@/lib/db";
|
import { query } from "@/lib/db";
|
||||||
|
import { loadTranslationsFor } from "@/lib/db/translations";
|
||||||
|
import { listActiveLanguageCodes } from "@/lib/db/languages";
|
||||||
import { authErrorResponse, requireRole } from "@/lib/auth/session";
|
import { authErrorResponse, requireRole } from "@/lib/auth/session";
|
||||||
import { toCsv } from "@/lib/csv";
|
import { toCsv } from "@/lib/csv";
|
||||||
import type { Dish } from "@/lib/types";
|
|
||||||
|
interface DishRow {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
price: string;
|
||||||
|
allergens: string[];
|
||||||
|
additives: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
await requireRole(["staff", "admin"]);
|
await requireRole(["staff", "admin"]);
|
||||||
const dishes = await query<Dish>("select * from dishes order by name");
|
const [dishes, activeCodes] = await Promise.all([
|
||||||
|
query<DishRow>("select id, name, price, allergens, additives from dishes order by name"),
|
||||||
|
listActiveLanguageCodes(),
|
||||||
|
]);
|
||||||
|
const langCodes = activeCodes.filter((c) => c !== "de");
|
||||||
|
const translations = await loadTranslationsFor(
|
||||||
|
"dish_translations",
|
||||||
|
dishes.map((d) => d.id)
|
||||||
|
);
|
||||||
const rows = [
|
const rows = [
|
||||||
["name", "name_en", "name_pl", "price", "allergens", "additives"],
|
["name", ...langCodes.map((c) => `name_${c}`), "price", "allergens", "additives"],
|
||||||
...dishes.map((d) => [
|
...dishes.map((d) => [
|
||||||
d.name,
|
d.name,
|
||||||
d.name_en ?? "",
|
...langCodes.map((c) => translations.get(d.id)?.[c] ?? ""),
|
||||||
d.name_pl ?? "",
|
d.price,
|
||||||
String(d.price),
|
|
||||||
(d.allergens ?? []).join(";"),
|
(d.allergens ?? []).join(";"),
|
||||||
(d.additives ?? []).join(";"),
|
(d.additives ?? []).join(";"),
|
||||||
]),
|
]),
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { withTransaction } from "@/lib/db";
|
import { withTransaction } from "@/lib/db";
|
||||||
|
import { replaceTranslations } from "@/lib/db/translations";
|
||||||
|
import { listActiveLanguageCodes } from "@/lib/db/languages";
|
||||||
import { authErrorResponse, requireRole } from "@/lib/auth/session";
|
import { authErrorResponse, requireRole } from "@/lib/auth/session";
|
||||||
import { isSameOrigin } from "@/lib/origin";
|
import { isSameOrigin } from "@/lib/origin";
|
||||||
import { parseCsvWithHeader, splitCodes } from "@/lib/csv";
|
import { parseCsvWithHeader, splitCodes } from "@/lib/csv";
|
||||||
@@ -7,8 +9,7 @@ import { parseCsvWithHeader, splitCodes } from "@/lib/csv";
|
|||||||
interface ImportRow {
|
interface ImportRow {
|
||||||
rowNum: number;
|
rowNum: number;
|
||||||
name: string;
|
name: string;
|
||||||
name_en: string | null;
|
translations: Record<string, string>;
|
||||||
name_pl: string | null;
|
|
||||||
price: number;
|
price: number;
|
||||||
allergens: string[];
|
allergens: string[];
|
||||||
additives: string[];
|
additives: string[];
|
||||||
@@ -32,8 +33,10 @@ export async function POST(request: Request) {
|
|||||||
if (nameIdx === -1 || priceIdx === -1) {
|
if (nameIdx === -1 || priceIdx === -1) {
|
||||||
return NextResponse.json({ error: "missing_columns" }, { status: 400 });
|
return NextResponse.json({ error: "missing_columns" }, { status: 400 });
|
||||||
}
|
}
|
||||||
const nameEnIdx = header.indexOf("name_en");
|
const activeCodes = (await listActiveLanguageCodes()).filter((c) => c !== "de");
|
||||||
const namePlIdx = header.indexOf("name_pl");
|
const langCols = activeCodes
|
||||||
|
.map((code) => ({ code, idx: header.indexOf(`name_${code}`) }))
|
||||||
|
.filter((c) => c.idx !== -1);
|
||||||
const allergensIdx = header.indexOf("allergens");
|
const allergensIdx = header.indexOf("allergens");
|
||||||
const additivesIdx = header.indexOf("additives");
|
const additivesIdx = header.indexOf("additives");
|
||||||
|
|
||||||
@@ -52,11 +55,15 @@ export async function POST(request: Request) {
|
|||||||
skipped.push({ row: rowNum, reason: `Ungültiger Preis „${c[priceIdx] ?? ""}“` });
|
skipped.push({ row: rowNum, reason: `Ungültiger Preis „${c[priceIdx] ?? ""}“` });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const translations: Record<string, string> = {};
|
||||||
|
for (const { code, idx } of langCols) {
|
||||||
|
const value = c[idx]?.trim();
|
||||||
|
if (value) translations[code] = value;
|
||||||
|
}
|
||||||
rows.push({
|
rows.push({
|
||||||
rowNum,
|
rowNum,
|
||||||
name,
|
name,
|
||||||
name_en: nameEnIdx >= 0 ? c[nameEnIdx]?.trim() || null : null,
|
translations,
|
||||||
name_pl: namePlIdx >= 0 ? c[namePlIdx]?.trim() || null : null,
|
|
||||||
price,
|
price,
|
||||||
allergens: allergensIdx >= 0 ? splitCodes(c[allergensIdx]) : [],
|
allergens: allergensIdx >= 0 ? splitCodes(c[allergensIdx]) : [],
|
||||||
additives: additivesIdx >= 0 ? splitCodes(c[additivesIdx]) : [],
|
additives: additivesIdx >= 0 ? splitCodes(c[additivesIdx]) : [],
|
||||||
@@ -65,6 +72,7 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
let created = 0;
|
let created = 0;
|
||||||
let updated = 0;
|
let updated = 0;
|
||||||
|
const savedTranslations: { id: string; translations: Record<string, string> }[] = [];
|
||||||
if (rows.length > 0) {
|
if (rows.length > 0) {
|
||||||
await withTransaction(async (client) => {
|
await withTransaction(async (client) => {
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
@@ -84,21 +92,27 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
if (existing.rows.length === 1) {
|
if (existing.rows.length === 1) {
|
||||||
await client.query(
|
await client.query(
|
||||||
`update dishes set name_en = $1, name_pl = $2, price = $3, allergens = $4, additives = $5
|
`update dishes set price = $1, allergens = $2, additives = $3 where id = $4`,
|
||||||
where id = $6`,
|
[row.price, row.allergens, row.additives, existing.rows[0].id]
|
||||||
[row.name_en, row.name_pl, row.price, row.allergens, row.additives, existing.rows[0].id]
|
|
||||||
);
|
);
|
||||||
|
savedTranslations.push({ id: existing.rows[0].id, translations: row.translations });
|
||||||
updated++;
|
updated++;
|
||||||
} else {
|
} else {
|
||||||
await client.query(
|
const inserted = await client.query<{ id: string }>(
|
||||||
`insert into dishes (name, name_en, name_pl, price, allergens, additives)
|
`insert into dishes (name, price, allergens, additives)
|
||||||
values ($1, $2, $3, $4, $5, $6)`,
|
values ($1, $2, $3, $4) returning id`,
|
||||||
[row.name, row.name_en, row.name_pl, row.price, row.allergens, row.additives]
|
[row.name, row.price, row.allergens, row.additives]
|
||||||
);
|
);
|
||||||
|
savedTranslations.push({ id: inserted.rows[0].id, translations: row.translations });
|
||||||
created++;
|
created++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
for (const { id, translations } of savedTranslations) {
|
||||||
|
if (Object.keys(translations).length > 0) {
|
||||||
|
await replaceTranslations("dish_translations", id, translations);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ created, updated, skipped });
|
return NextResponse.json({ created, updated, skipped });
|
||||||
|
|||||||
+35
-9
@@ -1,12 +1,35 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { query, queryOne } from "@/lib/db";
|
import { query, queryOne } from "@/lib/db";
|
||||||
|
import { loadTranslationsFor, replaceTranslations } from "@/lib/db/translations";
|
||||||
import { authErrorResponse, requireRole } from "@/lib/auth/session";
|
import { authErrorResponse, requireRole } from "@/lib/auth/session";
|
||||||
import { isSameOrigin } from "@/lib/origin";
|
import { isSameOrigin } from "@/lib/origin";
|
||||||
import type { Dish } from "@/lib/types";
|
import type { Dish } from "@/lib/types";
|
||||||
|
|
||||||
|
interface DishRow {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
price: string;
|
||||||
|
allergens: string[];
|
||||||
|
additives: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withTranslations(rows: DishRow[]): Promise<Dish[]> {
|
||||||
|
const translations = await loadTranslationsFor(
|
||||||
|
"dish_translations",
|
||||||
|
rows.map((r) => r.id)
|
||||||
|
);
|
||||||
|
return rows.map((r) => ({
|
||||||
|
...r,
|
||||||
|
price: Number(r.price),
|
||||||
|
translations: translations.get(r.id) ?? {},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const dishes = await query<Dish>("select * from dishes order by name");
|
const rows = await query<DishRow>(
|
||||||
return NextResponse.json(dishes);
|
"select id, name, price, allergens, additives from dishes order by name"
|
||||||
|
);
|
||||||
|
return NextResponse.json(await withTranslations(rows));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
@@ -17,17 +40,20 @@ export async function POST(request: Request) {
|
|||||||
await requireRole(["staff", "admin"]);
|
await requireRole(["staff", "admin"]);
|
||||||
const body = (await request.json()) as {
|
const body = (await request.json()) as {
|
||||||
name: string;
|
name: string;
|
||||||
name_en: string | null;
|
|
||||||
name_pl: string | null;
|
|
||||||
price: number;
|
price: number;
|
||||||
allergens: string[];
|
allergens: string[];
|
||||||
additives: string[];
|
additives: string[];
|
||||||
|
translations?: Record<string, string | null>;
|
||||||
};
|
};
|
||||||
const dish = await queryOne<Dish>(
|
const row = (await queryOne<DishRow>(
|
||||||
`insert into dishes (name, name_en, name_pl, price, allergens, additives)
|
`insert into dishes (name, price, allergens, additives)
|
||||||
values ($1, $2, $3, $4, $5, $6) returning *`,
|
values ($1, $2, $3, $4) returning id, name, price, allergens, additives`,
|
||||||
[body.name, body.name_en, body.name_pl, body.price, body.allergens, body.additives]
|
[body.name, body.price, body.allergens, body.additives]
|
||||||
);
|
))!;
|
||||||
|
if (body.translations) {
|
||||||
|
await replaceTranslations("dish_translations", row.id, body.translations);
|
||||||
|
}
|
||||||
|
const [dish] = await withTranslations([row]);
|
||||||
return NextResponse.json(dish, { status: 201 });
|
return NextResponse.json(dish, { status: 201 });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const res = authErrorResponse(err);
|
const res = authErrorResponse(err);
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { query, queryOne } from "@/lib/db";
|
import { query, queryOne } from "@/lib/db";
|
||||||
|
import { loadTranslationsFor, replaceTranslations } from "@/lib/db/translations";
|
||||||
import { authErrorResponse, requireRole } from "@/lib/auth/session";
|
import { authErrorResponse, requireRole } from "@/lib/auth/session";
|
||||||
import { isSameOrigin } from "@/lib/origin";
|
import { isSameOrigin } from "@/lib/origin";
|
||||||
import type { Section } from "@/lib/types";
|
import type { Section } from "@/lib/types";
|
||||||
|
|
||||||
|
interface SectionRow {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
time_range: string;
|
||||||
|
sort: number;
|
||||||
|
}
|
||||||
|
|
||||||
export async function PATCH(
|
export async function PATCH(
|
||||||
request: Request,
|
request: Request,
|
||||||
{ params }: { params: Promise<{ key: string }> }
|
{ params }: { params: Promise<{ key: string }> }
|
||||||
@@ -15,25 +23,37 @@ export async function PATCH(
|
|||||||
await requireRole(["staff", "admin"]);
|
await requireRole(["staff", "admin"]);
|
||||||
const { key } = await params;
|
const { key } = await params;
|
||||||
const body = (await request.json()) as Partial<
|
const body = (await request.json()) as Partial<
|
||||||
Pick<Section, "label" | "label_en" | "label_pl" | "time_range" | "sort">
|
Pick<Section, "label" | "time_range" | "sort">
|
||||||
>;
|
> & { translations?: Record<string, string | null> };
|
||||||
|
|
||||||
const fields: string[] = [];
|
const fields: string[] = [];
|
||||||
const values: unknown[] = [];
|
const values: unknown[] = [];
|
||||||
for (const col of ["label", "label_en", "label_pl", "time_range", "sort"] as const) {
|
for (const col of ["label", "time_range", "sort"] as const) {
|
||||||
if (body[col] !== undefined) {
|
if (body[col] !== undefined) {
|
||||||
values.push(body[col]);
|
values.push(body[col]);
|
||||||
fields.push(`${col} = $${values.length}`);
|
fields.push(`${col} = $${values.length}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (fields.length === 0) return NextResponse.json({ error: "empty_update" }, { status: 400 });
|
let row: SectionRow | null;
|
||||||
|
if (fields.length > 0) {
|
||||||
values.push(key);
|
values.push(key);
|
||||||
|
row = await queryOne<SectionRow>(
|
||||||
const section = await queryOne<Section>(
|
`update sections set ${fields.join(", ")} where key = $${values.length}
|
||||||
`update sections set ${fields.join(", ")} where key = $${values.length} returning *`,
|
returning key, label, time_range, sort`,
|
||||||
values
|
values
|
||||||
);
|
);
|
||||||
if (!section) return NextResponse.json({ error: "not_found" }, { status: 404 });
|
} else {
|
||||||
|
row = await queryOne<SectionRow>(
|
||||||
|
"select key, label, time_range, sort from sections where key = $1",
|
||||||
|
[key]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!row) return NextResponse.json({ error: "not_found" }, { status: 404 });
|
||||||
|
if (body.translations) {
|
||||||
|
await replaceTranslations("section_translations", row.key, body.translations);
|
||||||
|
}
|
||||||
|
const translations = await loadTranslationsFor("section_translations", [row.key]);
|
||||||
|
const section: Section = { ...row, translations: translations.get(row.key) ?? {} };
|
||||||
return NextResponse.json(section);
|
return NextResponse.json(section);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const res = authErrorResponse(err);
|
const res = authErrorResponse(err);
|
||||||
|
|||||||
@@ -1,12 +1,30 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { query, queryOne } from "@/lib/db";
|
import { query, queryOne } from "@/lib/db";
|
||||||
|
import { loadTranslationsFor, replaceTranslations } from "@/lib/db/translations";
|
||||||
import { authErrorResponse, requireRole } from "@/lib/auth/session";
|
import { authErrorResponse, requireRole } from "@/lib/auth/session";
|
||||||
import { isSameOrigin } from "@/lib/origin";
|
import { isSameOrigin } from "@/lib/origin";
|
||||||
import type { Section } from "@/lib/types";
|
import type { Section } from "@/lib/types";
|
||||||
|
|
||||||
|
interface SectionRow {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
time_range: string;
|
||||||
|
sort: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withTranslations(rows: SectionRow[]): Promise<Section[]> {
|
||||||
|
const translations = await loadTranslationsFor(
|
||||||
|
"section_translations",
|
||||||
|
rows.map((r) => r.key)
|
||||||
|
);
|
||||||
|
return rows.map((r) => ({ ...r, translations: translations.get(r.key) ?? {} }));
|
||||||
|
}
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const sections = await query<Section>("select * from sections order by sort");
|
const rows = await query<SectionRow>(
|
||||||
return NextResponse.json(sections);
|
"select key, label, time_range, sort from sections order by sort"
|
||||||
|
);
|
||||||
|
return NextResponse.json(await withTranslations(rows));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
@@ -17,19 +35,22 @@ export async function POST(request: Request) {
|
|||||||
await requireRole(["staff", "admin"]);
|
await requireRole(["staff", "admin"]);
|
||||||
const body = (await request.json()) as {
|
const body = (await request.json()) as {
|
||||||
label: string;
|
label: string;
|
||||||
label_en: string | null;
|
|
||||||
label_pl: string | null;
|
|
||||||
time_range: string;
|
time_range: string;
|
||||||
|
translations?: Record<string, string | null>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const maxRow = await queryOne<{ sort: number }>(
|
const maxRow = await queryOne<{ sort: number }>(
|
||||||
"select sort from sections order by sort desc limit 1"
|
"select sort from sections order by sort desc limit 1"
|
||||||
);
|
);
|
||||||
const section = await queryOne<Section>(
|
const row = (await queryOne<SectionRow>(
|
||||||
`insert into sections (label, label_en, label_pl, time_range, sort)
|
`insert into sections (label, time_range, sort)
|
||||||
values ($1, $2, $3, $4, $5) returning *`,
|
values ($1, $2, $3) returning key, label, time_range, sort`,
|
||||||
[body.label, body.label_en, body.label_pl, body.time_range, (maxRow?.sort ?? 0) + 1]
|
[body.label, body.time_range, (maxRow?.sort ?? 0) + 1]
|
||||||
);
|
))!;
|
||||||
|
if (body.translations) {
|
||||||
|
await replaceTranslations("section_translations", row.key, body.translations);
|
||||||
|
}
|
||||||
|
const [section] = await withTranslations([row]);
|
||||||
return NextResponse.json(section, { status: 201 });
|
return NextResponse.json(section, { status: 201 });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const res = authErrorResponse(err);
|
const res = authErrorResponse(err);
|
||||||
|
|||||||
+43
-26
@@ -1,21 +1,32 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { query } from "@/lib/db";
|
import { listActiveLanguageCodes } from "@/lib/db/languages";
|
||||||
|
import { replaceTranslations, type TranslationTable } from "@/lib/db/translations";
|
||||||
import { requireRole, UnauthorizedError, ForbiddenError } from "@/lib/auth/session";
|
import { requireRole, UnauthorizedError, ForbiddenError } from "@/lib/auth/session";
|
||||||
import { isSameOrigin } from "@/lib/origin";
|
import { isSameOrigin } from "@/lib/origin";
|
||||||
|
|
||||||
// Übersetzt einen deutschen Text nach Englisch/Polnisch via DeepL und
|
// Übersetzt einen deutschen Text via DeepL in alle aktiven, von DeepL
|
||||||
// schreibt das Ergebnis in die _en/_pl-Spalte der angegebenen Zeile.
|
// unterstützten Sprachen und schreibt das Ergebnis in die passende
|
||||||
// Ohne DEEPL_API_KEY läuft die Route kontrolliert ins Leere (configured:
|
// *_translations-Tabelle. Ohne DEEPL_API_KEY läuft die Route kontrolliert
|
||||||
// false), damit das UI die manuelle Eingabe anbieten kann.
|
// ins Leere (configured: false), damit das UI die manuelle Eingabe anbietet.
|
||||||
|
|
||||||
const ALLOWED_TABLES: Record<string, { idColumn: string; textColumn: string }> = {
|
const ALLOWED_TABLES: Record<string, { idColumn: string; translationTable: TranslationTable }> = {
|
||||||
dishes: { idColumn: "id", textColumn: "name" },
|
dishes: { idColumn: "id", translationTable: "dish_translations" },
|
||||||
sections: { idColumn: "key", textColumn: "label" },
|
sections: { idColumn: "key", translationTable: "section_translations" },
|
||||||
allergens: { idColumn: "code", textColumn: "name" },
|
allergens: { idColumn: "code", translationTable: "allergen_translations" },
|
||||||
additives: { idColumn: "code", textColumn: "name" },
|
additives: { idColumn: "code", translationTable: "additive_translations" },
|
||||||
|
closed_days: { idColumn: "closed_date", translationTable: "closed_day_translations" },
|
||||||
};
|
};
|
||||||
|
|
||||||
async function translateWithDeepL(text: string, targetLang: "EN" | "PL", apiKey: string) {
|
// Unsere Sprachcodes -> DeepL target_lang. Sprachen ohne Eintrag hier sind
|
||||||
|
// von DeepL nicht unterstützt und bleiben bei manueller Eingabe.
|
||||||
|
const DEEPL_TARGET_LANG: Record<string, string> = {
|
||||||
|
en: "EN", pl: "PL", fr: "FR", es: "ES", it: "IT", nl: "NL", pt: "PT-PT",
|
||||||
|
ru: "RU", ja: "JA", zh: "ZH", cs: "CS", da: "DA", el: "EL", fi: "FI",
|
||||||
|
hu: "HU", id: "ID", ko: "KO", nb: "NB", ro: "RO", sk: "SK", sl: "SL",
|
||||||
|
sv: "SV", tr: "TR", uk: "UK", bg: "BG", et: "ET", lv: "LV", lt: "LT",
|
||||||
|
};
|
||||||
|
|
||||||
|
async function translateWithDeepL(text: string, targetLang: string, apiKey: string) {
|
||||||
const host = apiKey.endsWith(":fx")
|
const host = apiKey.endsWith(":fx")
|
||||||
? "https://api-free.deepl.com"
|
? "https://api-free.deepl.com"
|
||||||
: "https://api.deepl.com";
|
: "https://api.deepl.com";
|
||||||
@@ -71,25 +82,31 @@ export async function POST(request: Request) {
|
|||||||
return NextResponse.json({ configured: false });
|
return NextResponse.json({ configured: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const activeCodes = (await listActiveLanguageCodes()).filter((c) => c !== "de");
|
||||||
const [en, pl] = await Promise.all([
|
const targets = activeCodes
|
||||||
translateWithDeepL(text, "EN", apiKey),
|
.map((code) => ({ code, target: DEEPL_TARGET_LANG[code.toLowerCase()] }))
|
||||||
translateWithDeepL(text, "PL", apiKey),
|
.filter((t): t is { code: string; target: string } => Boolean(t.target));
|
||||||
]);
|
|
||||||
if (!en || !pl) throw new Error("Keine Übersetzung erhalten.");
|
|
||||||
|
|
||||||
if (persist) {
|
try {
|
||||||
// table/idColumn/textColumn kommen ausschließlich aus dem oben
|
const results = await Promise.all(
|
||||||
// geprüften ALLOWED_TABLES-Whitelist, nie aus Nutzereingaben –
|
targets.map(async (t) => ({
|
||||||
// sicher für die String-Interpolation in der Query.
|
code: t.code,
|
||||||
await query(
|
text: await translateWithDeepL(text, t.target, apiKey),
|
||||||
`update ${table} set ${config.textColumn}_en = $1, ${config.textColumn}_pl = $2
|
}))
|
||||||
where ${config.idColumn} = $3`,
|
|
||||||
[en, pl, id]
|
|
||||||
);
|
);
|
||||||
|
const translations: Record<string, string> = {};
|
||||||
|
for (const r of results) {
|
||||||
|
if (r.text) translations[r.code] = r.text;
|
||||||
|
}
|
||||||
|
if (Object.keys(translations).length === 0) {
|
||||||
|
throw new Error("Keine Übersetzung erhalten.");
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ configured: true, en, pl });
|
if (persist) {
|
||||||
|
await replaceTranslations(config.translationTable, id, translations);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ configured: true, translations });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: err instanceof Error ? err.message : "Übersetzung fehlgeschlagen." },
|
{ error: err instanceof Error ? err.message : "Übersetzung fehlgeschlagen." },
|
||||||
|
|||||||
+12
-3
@@ -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,16 +46,24 @@ 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);
|
||||||
|
const dayContent = closed
|
||||||
|
? `<p><strong>Geschlossen</strong>${
|
||||||
|
closed.reason ? ` – ${escapeXml(closed.reason)}` : ""
|
||||||
|
}</p>`
|
||||||
|
: sections
|
||||||
.map((s) => {
|
.map((s) => {
|
||||||
const dishes = entriesFor(entries, iso, s.key);
|
const dishes = entriesFor(entries, iso, s.key);
|
||||||
if (dishes.length === 0) return "";
|
if (dishes.length === 0) return "";
|
||||||
|
|||||||
+6
-1
@@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+55
-7
@@ -1,6 +1,8 @@
|
|||||||
import Link from "next/link";
|
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 { listClosedDays } from "@/lib/db/closed-days";
|
||||||
import { getSettings } from "@/lib/db/settings";
|
import { getSettings } from "@/lib/db/settings";
|
||||||
import {
|
import {
|
||||||
addDays,
|
addDays,
|
||||||
@@ -33,13 +35,41 @@ export default async function Home({
|
|||||||
iso: addDays(monday, i),
|
iso: addDays(monday, i),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const [sections, entries, allergens, additives, settings] = await Promise.all([
|
const [
|
||||||
query<Section>("select * from sections order by sort"),
|
sectionRows,
|
||||||
|
entries,
|
||||||
|
allergenRows,
|
||||||
|
additiveRows,
|
||||||
|
settings,
|
||||||
|
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),
|
loadMenuEntries(monday, friday),
|
||||||
query<CodeItem>("select * from allergens order by sort"),
|
query<Omit<CodeItem, "translations">>("select code, name, sort from allergens order by sort"),
|
||||||
query<CodeItem>("select * from additives order by sort"),
|
query<Omit<CodeItem, "translations">>("select code, name, sort from additives order by sort"),
|
||||||
getSettings(),
|
getSettings(),
|
||||||
|
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,
|
||||||
|
translations: sectionTranslations.get(s.key) ?? {},
|
||||||
|
}));
|
||||||
|
const allergens: CodeItem[] = allergenRows.map((a) => ({
|
||||||
|
...a,
|
||||||
|
translations: allergenTranslations.get(a.code) ?? {},
|
||||||
|
}));
|
||||||
|
const additives: CodeItem[] = additiveRows.map((a) => ({
|
||||||
|
...a,
|
||||||
|
translations: additiveTranslations.get(a.code) ?? {},
|
||||||
|
}));
|
||||||
|
|
||||||
const logo = logoUrl(settings?.logo_path);
|
const logo = logoUrl(settings?.logo_path);
|
||||||
const labelingDetailed = (settings?.labeling_mode ?? "detailed") !== "notice";
|
const labelingDetailed = (settings?.labeling_mode ?? "detailed") !== "notice";
|
||||||
@@ -104,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}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -129,13 +160,28 @@ export default async function Home({
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th className="section-head w-56" aria-hidden />
|
<th className="section-head w-56" aria-hidden />
|
||||||
{days.map((d) => (
|
{days.map((d) => {
|
||||||
|
const closed = closedDayFor(d.iso);
|
||||||
|
return (
|
||||||
<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)}
|
||||||
|
{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>
|
</th>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -147,7 +193,9 @@ export default async function Home({
|
|||||||
</th>
|
</th>
|
||||||
{days.map((d) => (
|
{days.map((d) => (
|
||||||
<td key={d.iso} className="cell">
|
<td key={d.iso} className="cell">
|
||||||
{entriesFor(entries, d.iso, s.key).map((e) => {
|
{closedDayFor(d.iso)
|
||||||
|
? null
|
||||||
|
: entriesFor(entries, d.iso, s.key).map((e) => {
|
||||||
const codes = dishCodes(e.dish);
|
const codes = dishCodes(e.dish);
|
||||||
return (
|
return (
|
||||||
<div key={e.id} className="dish">
|
<div key={e.id} className="dish">
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { useCallback, useEffect, useState } from "react";
|
|||||||
import { formatMessage } from "@/lib/i18n/shared";
|
import { formatMessage } from "@/lib/i18n/shared";
|
||||||
import { localized } from "@/lib/types";
|
import { localized } from "@/lib/types";
|
||||||
import type { CodeItem } from "@/lib/types";
|
import type { CodeItem } from "@/lib/types";
|
||||||
import { useDict, useLocale } from "@/components/i18n/LocaleProvider";
|
import { useDict, useLanguages, useLocale } from "@/components/i18n/LocaleProvider";
|
||||||
|
|
||||||
export default function CodeListManager({
|
export default function CodeListManager({
|
||||||
table,
|
table,
|
||||||
@@ -18,6 +18,7 @@ export default function CodeListManager({
|
|||||||
}) {
|
}) {
|
||||||
const dict = useDict();
|
const dict = useDict();
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
|
const languages = useLanguages().filter((l) => l.code !== "de");
|
||||||
const title =
|
const title =
|
||||||
table === "allergens" ? dict.codeList.allergensTitle : dict.codeList.additivesTitle;
|
table === "allergens" ? dict.codeList.allergensTitle : dict.codeList.additivesTitle;
|
||||||
const codeLabel =
|
const codeLabel =
|
||||||
@@ -30,8 +31,7 @@ export default function CodeListManager({
|
|||||||
original: string | null; // code vor der Bearbeitung, null = neu
|
original: string | null; // code vor der Bearbeitung, null = neu
|
||||||
code: string;
|
code: string;
|
||||||
name: string;
|
name: string;
|
||||||
nameEn: string;
|
translations: Record<string, string>;
|
||||||
namePl: string;
|
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [notice, setNotice] = useState<string | null>(null);
|
const [notice, setNotice] = useState<string | null>(null);
|
||||||
@@ -60,8 +60,7 @@ export default function CodeListManager({
|
|||||||
const payload = {
|
const payload = {
|
||||||
code,
|
code,
|
||||||
name,
|
name,
|
||||||
name_en: editing.nameEn.trim() || null,
|
translations: editing.translations,
|
||||||
name_pl: editing.namePl.trim() || null,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const res = editing.original
|
const res = editing.original
|
||||||
@@ -118,7 +117,9 @@ export default function CodeListManager({
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!res.ok) throw new Error(data?.error ?? `Fehler (${res.status})`);
|
if (!res.ok) throw new Error(data?.error ?? `Fehler (${res.status})`);
|
||||||
setEditing((prev) =>
|
setEditing((prev) =>
|
||||||
prev ? { ...prev, nameEn: data.en ?? prev.nameEn, namePl: data.pl ?? prev.namePl } : prev
|
prev
|
||||||
|
? { ...prev, translations: { ...prev.translations, ...(data.translations ?? {}) } }
|
||||||
|
: prev
|
||||||
);
|
);
|
||||||
setTranslateState("done");
|
setTranslateState("done");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -202,7 +203,7 @@ export default function CodeListManager({
|
|||||||
type="button"
|
type="button"
|
||||||
className="btn btn-add"
|
className="btn btn-add"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setEditing({ original: null, code: "", name: "", nameEn: "", namePl: "" });
|
setEditing({ original: null, code: "", name: "", translations: {} });
|
||||||
setTranslateState("idle");
|
setTranslateState("idle");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -237,25 +238,26 @@ export default function CodeListManager({
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{languages.length > 0 && (
|
||||||
<div className="grid gap-2 sm:grid-cols-2">
|
<div className="grid gap-2 sm:grid-cols-2">
|
||||||
<div>
|
{languages.map((lang) => (
|
||||||
<label className="label">{dict.dishManager.nameEnLabel}</label>
|
<div key={lang.code}>
|
||||||
|
<label className="label">{lang.label}</label>
|
||||||
<input
|
<input
|
||||||
className="input"
|
className="input"
|
||||||
value={editing.nameEn}
|
value={editing.translations[lang.code] ?? ""}
|
||||||
onChange={(e) => setEditing({ ...editing, nameEn: e.target.value })}
|
onChange={(e) =>
|
||||||
|
setEditing({
|
||||||
|
...editing,
|
||||||
|
translations: { ...editing.translations, [lang.code]: e.target.value },
|
||||||
|
})
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
))}
|
||||||
<label className="label">{dict.dishManager.namePlLabel}</label>
|
|
||||||
<input
|
|
||||||
className="input"
|
|
||||||
value={editing.namePl}
|
|
||||||
onChange={(e) => setEditing({ ...editing, namePl: e.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
{translateEnabled && (
|
{translateEnabled && languages.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -304,8 +306,7 @@ export default function CodeListManager({
|
|||||||
original: item.code,
|
original: item.code,
|
||||||
code: item.code,
|
code: item.code,
|
||||||
name: item.name,
|
name: item.name,
|
||||||
nameEn: item.name_en ?? "",
|
translations: { ...item.translations },
|
||||||
namePl: item.name_pl ?? "",
|
|
||||||
});
|
});
|
||||||
setTranslateState("idle");
|
setTranslateState("idle");
|
||||||
}}
|
}}
|
||||||
|
|||||||
+25
-28
@@ -6,13 +6,12 @@ import { formatMessage } from "@/lib/i18n/shared";
|
|||||||
import { localized } from "@/lib/types";
|
import { localized } from "@/lib/types";
|
||||||
import type { CodeItem, Dish } from "@/lib/types";
|
import type { CodeItem, Dish } from "@/lib/types";
|
||||||
import ChipPicker from "@/components/ChipPicker";
|
import ChipPicker from "@/components/ChipPicker";
|
||||||
import { useDict, useLocale } from "@/components/i18n/LocaleProvider";
|
import { useDict, useLanguages, useLocale } from "@/components/i18n/LocaleProvider";
|
||||||
|
|
||||||
interface DishForm {
|
interface DishForm {
|
||||||
id: string | null; // null = neues Gericht
|
id: string | null; // null = neues Gericht
|
||||||
name: string;
|
name: string;
|
||||||
nameEn: string;
|
translations: Record<string, string>;
|
||||||
namePl: string;
|
|
||||||
price: string;
|
price: string;
|
||||||
allergens: string[];
|
allergens: string[];
|
||||||
additives: string[];
|
additives: string[];
|
||||||
@@ -21,8 +20,7 @@ interface DishForm {
|
|||||||
const EMPTY_FORM: DishForm = {
|
const EMPTY_FORM: DishForm = {
|
||||||
id: null,
|
id: null,
|
||||||
name: "",
|
name: "",
|
||||||
nameEn: "",
|
translations: {},
|
||||||
namePl: "",
|
|
||||||
price: "",
|
price: "",
|
||||||
allergens: [],
|
allergens: [],
|
||||||
additives: [],
|
additives: [],
|
||||||
@@ -35,6 +33,7 @@ export default function DishManager({
|
|||||||
}) {
|
}) {
|
||||||
const dict = useDict();
|
const dict = useDict();
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
|
const languages = useLanguages().filter((l) => l.code !== "de");
|
||||||
const [dishes, setDishes] = useState<Dish[]>([]);
|
const [dishes, setDishes] = useState<Dish[]>([]);
|
||||||
const [allergens, setAllergens] = useState<CodeItem[]>([]);
|
const [allergens, setAllergens] = useState<CodeItem[]>([]);
|
||||||
const [additives, setAdditives] = useState<CodeItem[]>([]);
|
const [additives, setAdditives] = useState<CodeItem[]>([]);
|
||||||
@@ -93,11 +92,10 @@ export default function DishManager({
|
|||||||
}
|
}
|
||||||
const payload = {
|
const payload = {
|
||||||
name: form.name.trim(),
|
name: form.name.trim(),
|
||||||
name_en: form.nameEn.trim() || null,
|
|
||||||
name_pl: form.namePl.trim() || null,
|
|
||||||
price,
|
price,
|
||||||
allergens: form.allergens,
|
allergens: form.allergens,
|
||||||
additives: form.additives,
|
additives: form.additives,
|
||||||
|
translations: form.translations,
|
||||||
};
|
};
|
||||||
const res = form.id
|
const res = form.id
|
||||||
? await fetch(`/api/dishes/${form.id}`, {
|
? await fetch(`/api/dishes/${form.id}`, {
|
||||||
@@ -218,7 +216,9 @@ export default function DishManager({
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!res.ok) throw new Error(data?.error ?? `Fehler (${res.status})`);
|
if (!res.ok) throw new Error(data?.error ?? `Fehler (${res.status})`);
|
||||||
setForm((prev) =>
|
setForm((prev) =>
|
||||||
prev ? { ...prev, nameEn: data.en ?? prev.nameEn, namePl: data.pl ?? prev.namePl } : prev
|
prev
|
||||||
|
? { ...prev, translations: { ...prev.translations, ...(data.translations ?? {}) } }
|
||||||
|
: prev
|
||||||
);
|
);
|
||||||
setTranslateState("done");
|
setTranslateState("done");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -352,31 +352,29 @@ export default function DishManager({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{languages.length > 0 && (
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
<div>
|
{languages.map((lang) => (
|
||||||
<label className="label" htmlFor="dish-name-en">
|
<div key={lang.code}>
|
||||||
{dict.dishManager.nameEnLabel}
|
<label className="label" htmlFor={`dish-name-${lang.code}`}>
|
||||||
|
{lang.label}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="dish-name-en"
|
id={`dish-name-${lang.code}`}
|
||||||
className="input"
|
className="input"
|
||||||
value={form.nameEn}
|
value={form.translations[lang.code] ?? ""}
|
||||||
onChange={(e) => setForm({ ...form, nameEn: e.target.value })}
|
onChange={(e) =>
|
||||||
|
setForm({
|
||||||
|
...form,
|
||||||
|
translations: { ...form.translations, [lang.code]: e.target.value },
|
||||||
|
})
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
))}
|
||||||
<label className="label" htmlFor="dish-name-pl">
|
|
||||||
{dict.dishManager.namePlLabel}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="dish-name-pl"
|
|
||||||
className="input"
|
|
||||||
value={form.namePl}
|
|
||||||
onChange={(e) => setForm({ ...form, namePl: e.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
{translateEnabled && (
|
{translateEnabled && languages.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -495,8 +493,7 @@ export default function DishManager({
|
|||||||
setForm({
|
setForm({
|
||||||
id: dish.id,
|
id: dish.id,
|
||||||
name: dish.name,
|
name: dish.name,
|
||||||
nameEn: dish.name_en ?? "",
|
translations: { ...dish.translations },
|
||||||
namePl: dish.name_pl ?? "",
|
|
||||||
price: String(dish.price).replace(".", ","),
|
price: String(dish.price).replace(".", ","),
|
||||||
allergens: dish.allergens ?? [],
|
allergens: dish.allergens ?? [],
|
||||||
additives: dish.additives ?? [],
|
additives: dish.additives ?? [],
|
||||||
|
|||||||
@@ -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,11 +62,18 @@ 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>
|
||||||
|
{closed ? (
|
||||||
|
<div className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||||
|
<span className="font-semibold">{dict.publicMenu.closedLabel}</span>
|
||||||
|
{localized(closed, locale) && <span> – {localized(closed, locale)}</span>}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{sections.map((s) => {
|
{sections.map((s) => {
|
||||||
const items = entriesFor(entries, day.iso, s.key);
|
const items = entriesFor(entries, day.iso, s.key);
|
||||||
@@ -101,6 +110,7 @@ export default function PublicMobileMenu({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,13 +7,12 @@ import { useCallback, useEffect, useState } from "react";
|
|||||||
import { formatMessage } from "@/lib/i18n/shared";
|
import { formatMessage } from "@/lib/i18n/shared";
|
||||||
import { localized } from "@/lib/types";
|
import { localized } from "@/lib/types";
|
||||||
import type { Section } from "@/lib/types";
|
import type { Section } from "@/lib/types";
|
||||||
import { useDict, useLocale } from "@/components/i18n/LocaleProvider";
|
import { useDict, useLanguages, useLocale } from "@/components/i18n/LocaleProvider";
|
||||||
|
|
||||||
interface SectionForm {
|
interface SectionForm {
|
||||||
key: string | null; // null = neue Essenszeit
|
key: string | null; // null = neue Essenszeit
|
||||||
label: string;
|
label: string;
|
||||||
labelEn: string;
|
translations: Record<string, string>;
|
||||||
labelPl: string;
|
|
||||||
timeFrom: string;
|
timeFrom: string;
|
||||||
timeTo: string;
|
timeTo: string;
|
||||||
}
|
}
|
||||||
@@ -21,8 +20,7 @@ interface SectionForm {
|
|||||||
const EMPTY_FORM: SectionForm = {
|
const EMPTY_FORM: SectionForm = {
|
||||||
key: null,
|
key: null,
|
||||||
label: "",
|
label: "",
|
||||||
labelEn: "",
|
translations: {},
|
||||||
labelPl: "",
|
|
||||||
timeFrom: "",
|
timeFrom: "",
|
||||||
timeTo: "",
|
timeTo: "",
|
||||||
};
|
};
|
||||||
@@ -35,6 +33,7 @@ function splitTimeRange(range: string): { from: string; to: string } {
|
|||||||
export default function SectionManager() {
|
export default function SectionManager() {
|
||||||
const dict = useDict();
|
const dict = useDict();
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
|
const languages = useLanguages().filter((l) => l.code !== "de");
|
||||||
const [sections, setSections] = useState<Section[]>([]);
|
const [sections, setSections] = useState<Section[]>([]);
|
||||||
const [form, setForm] = useState<SectionForm | null>(null);
|
const [form, setForm] = useState<SectionForm | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -61,9 +60,8 @@ export default function SectionManager() {
|
|||||||
if (!label || !form.timeFrom || !form.timeTo) return;
|
if (!label || !form.timeFrom || !form.timeTo) return;
|
||||||
const payload = {
|
const payload = {
|
||||||
label,
|
label,
|
||||||
label_en: form.labelEn.trim() || null,
|
|
||||||
label_pl: form.labelPl.trim() || null,
|
|
||||||
time_range: `${form.timeFrom} - ${form.timeTo}`,
|
time_range: `${form.timeFrom} - ${form.timeTo}`,
|
||||||
|
translations: form.translations,
|
||||||
};
|
};
|
||||||
|
|
||||||
const res = form.key
|
const res = form.key
|
||||||
@@ -160,30 +158,28 @@ export default function SectionManager() {
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{languages.length > 0 && (
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
<div>
|
{languages.map((lang) => (
|
||||||
<label className="label" htmlFor="section-label-en">
|
<div key={lang.code}>
|
||||||
{dict.sectionManager.nameEnLabel}
|
<label className="label" htmlFor={`section-label-${lang.code}`}>
|
||||||
|
{lang.label}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="section-label-en"
|
id={`section-label-${lang.code}`}
|
||||||
className="input"
|
className="input"
|
||||||
value={form.labelEn}
|
value={form.translations[lang.code] ?? ""}
|
||||||
onChange={(e) => setForm({ ...form, labelEn: e.target.value })}
|
onChange={(e) =>
|
||||||
/>
|
setForm({
|
||||||
</div>
|
...form,
|
||||||
<div>
|
translations: { ...form.translations, [lang.code]: e.target.value },
|
||||||
<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>
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<div className="max-w-32">
|
<div className="max-w-32">
|
||||||
<label className="label" htmlFor="section-time-from">
|
<label className="label" htmlFor="section-time-from">
|
||||||
@@ -267,8 +263,7 @@ export default function SectionManager() {
|
|||||||
setForm({
|
setForm({
|
||||||
key: section.key,
|
key: section.key,
|
||||||
label: section.label,
|
label: section.label,
|
||||||
labelEn: section.label_en ?? "",
|
translations: { ...section.translations },
|
||||||
labelPl: section.label_pl ?? "",
|
|
||||||
timeFrom: from,
|
timeFrom: from,
|
||||||
timeTo: to,
|
timeTo: to,
|
||||||
});
|
});
|
||||||
|
|||||||
+220
-7
@@ -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,121 @@ 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;
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<div className="mt-1 flex flex-wrap items-center justify-center gap-1 text-xs">
|
||||||
|
<span className="rounded-full bg-red-100 text-red-700 px-2 py-0.5 font-semibold">
|
||||||
|
{dict.weekEditor.closedLabel}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-gray-400 hover:text-[var(--c-primary)] underline"
|
||||||
|
onClick={() => reopenDay(iso)}
|
||||||
|
disabled={closeBusy}
|
||||||
|
>
|
||||||
|
{dict.weekEditor.reopenDay}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="mt-1 text-xs text-gray-400 hover:text-[var(--c-primary)] underline"
|
||||||
|
onClick={() => startClosing(iso)}
|
||||||
|
>
|
||||||
|
{dict.weekEditor.closeDay}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
@@ -121,6 +330,7 @@ export default function WeekEditor({ monday }: { monday: string }) {
|
|||||||
// unabhängig davon, welche Ansicht gerade rendert.
|
// unabhängig davon, welche Ansicht gerade rendert.
|
||||||
function renderCell(iso: string, sectionKey: string) {
|
function renderCell(iso: string, sectionKey: string) {
|
||||||
const isAdding = adding?.iso === iso && adding?.section === sectionKey;
|
const isAdding = adding?.iso === iso && adding?.section === sectionKey;
|
||||||
|
const closed = Boolean(closedDayFor(iso));
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{entriesFor(entries, iso, sectionKey).map((e) => {
|
{entriesFor(entries, iso, sectionKey).map((e) => {
|
||||||
@@ -143,7 +353,7 @@ export default function WeekEditor({ monday }: { monday: string }) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{isAdding ? (
|
{closed ? null : isAdding ? (
|
||||||
<div className="mt-2 space-y-1 text-left">
|
<div className="mt-2 space-y-1 text-left">
|
||||||
{selectedDishObj ? (
|
{selectedDishObj ? (
|
||||||
<div className="flex items-center gap-1 text-sm">
|
<div className="flex items-center gap-1 text-sm">
|
||||||
@@ -250,6 +460,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,9 +483,10 @@ 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)}
|
||||||
</h2>
|
</h2>
|
||||||
|
<div className="mb-2">{renderClosureBadge(selectedDay.iso)}</div>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{sections.map((s) => (
|
{sections.map((s) => (
|
||||||
<div key={s.key} className="rounded-md border border-gray-200 overflow-hidden">
|
<div key={s.key} className="rounded-md border border-gray-200 overflow-hidden">
|
||||||
@@ -301,6 +513,7 @@ export default function WeekEditor({ monday }: { monday: string }) {
|
|||||||
<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)}
|
||||||
|
{renderClosureBadge(d.iso)}
|
||||||
</th>
|
</th>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
-- Teil 2 des "mehrsprachig variabel"-Umbaus: Speiseplan-Inhalte
|
||||||
|
-- (Gerichte, Essenszeiten, Allergene, Zusatzstoffe) bekommen eine
|
||||||
|
-- Übersetzungstabelle je Ressource statt fester name_en/name_pl-
|
||||||
|
-- Spalten (Teil 1, v1.10.0, hat dasselbe schon für die
|
||||||
|
-- Bedienoberfläche gemacht). Deutsch bleibt die Basis-Spalte
|
||||||
|
-- (name/label) auf der jeweiligen Haupttabelle.
|
||||||
|
--
|
||||||
|
-- WICHTIG bei Deployments mit echten Daten: vor dem Einspielen ein
|
||||||
|
-- Backup ziehen (siehe DEPLOYMENT.md, Abschnitt "Backup") – Schritt 3
|
||||||
|
-- entfernt die alten name_en/name_pl-Spalten unwiderruflich, nachdem
|
||||||
|
-- Schritt 2 ihren Inhalt in die neuen Tabellen übernommen hat.
|
||||||
|
|
||||||
|
-- ---------- Schritt 1: neue Tabellen ----------
|
||||||
|
create table dish_translations (
|
||||||
|
dish_id uuid not null references dishes(id) on delete cascade,
|
||||||
|
language_code text not null references languages(code) on delete cascade,
|
||||||
|
name text not null,
|
||||||
|
primary key (dish_id, language_code)
|
||||||
|
);
|
||||||
|
|
||||||
|
create table section_translations (
|
||||||
|
section_key text not null references sections(key) on delete cascade,
|
||||||
|
language_code text not null references languages(code) on delete cascade,
|
||||||
|
name text not null,
|
||||||
|
primary key (section_key, language_code)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- allergens.code/additives.code sind vom Admin umbenennbar (Primary
|
||||||
|
-- Key), anders als dishes.id/sections.key – "on update cascade" nötig,
|
||||||
|
-- damit eine Umbenennung des Kürzels nicht die Übersetzungszeilen
|
||||||
|
-- verwaist.
|
||||||
|
create table allergen_translations (
|
||||||
|
allergen_code text not null references allergens(code) on delete cascade on update cascade,
|
||||||
|
language_code text not null references languages(code) on delete cascade,
|
||||||
|
name text not null,
|
||||||
|
primary key (allergen_code, language_code)
|
||||||
|
);
|
||||||
|
|
||||||
|
create table additive_translations (
|
||||||
|
additive_code text not null references additives(code) on delete cascade on update cascade,
|
||||||
|
language_code text not null references languages(code) on delete cascade,
|
||||||
|
name text not null,
|
||||||
|
primary key (additive_code, language_code)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ---------- Schritt 2: bestehende Übersetzungen übernehmen ----------
|
||||||
|
insert into dish_translations (dish_id, language_code, name)
|
||||||
|
select id, 'en', name_en from dishes where name_en is not null and trim(name_en) != '';
|
||||||
|
insert into dish_translations (dish_id, language_code, name)
|
||||||
|
select id, 'pl', name_pl from dishes where name_pl is not null and trim(name_pl) != '';
|
||||||
|
|
||||||
|
insert into section_translations (section_key, language_code, name)
|
||||||
|
select key, 'en', label_en from sections where label_en is not null and trim(label_en) != '';
|
||||||
|
insert into section_translations (section_key, language_code, name)
|
||||||
|
select key, 'pl', label_pl from sections where label_pl is not null and trim(label_pl) != '';
|
||||||
|
|
||||||
|
insert into allergen_translations (allergen_code, language_code, name)
|
||||||
|
select code, 'en', name_en from allergens where name_en is not null and trim(name_en) != '';
|
||||||
|
insert into allergen_translations (allergen_code, language_code, name)
|
||||||
|
select code, 'pl', name_pl from allergens where name_pl is not null and trim(name_pl) != '';
|
||||||
|
|
||||||
|
insert into additive_translations (additive_code, language_code, name)
|
||||||
|
select code, 'en', name_en from additives where name_en is not null and trim(name_en) != '';
|
||||||
|
insert into additive_translations (additive_code, language_code, name)
|
||||||
|
select code, 'pl', name_pl from additives where name_pl is not null and trim(name_pl) != '';
|
||||||
|
|
||||||
|
-- ---------- Schritt 3: alte Spalten entfernen ----------
|
||||||
|
alter table dishes drop column name_en, drop column name_pl;
|
||||||
|
alter table sections drop column label_en, drop column label_pl;
|
||||||
|
alter table allergens drop column name_en, drop column name_pl;
|
||||||
|
alter table additives drop column name_en, drop column name_pl;
|
||||||
@@ -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)
|
||||||
|
);
|
||||||
+71
-32
@@ -16,42 +16,81 @@ delete from allergens;
|
|||||||
delete from additives;
|
delete from additives;
|
||||||
|
|
||||||
-- ---------- Essenszeiten ----------
|
-- ---------- Essenszeiten ----------
|
||||||
insert into sections (key, label, label_en, label_pl, time_range, sort) values
|
insert into sections (key, label, time_range, sort) values
|
||||||
('brotzeit', 'Brotzeit', 'Breakfast', 'Śniadanie', '09:00 - 10:00', 1),
|
('brotzeit', 'Brotzeit', '09:00 - 10:00', 1),
|
||||||
('mittagessen', 'Mittagessen', 'Lunch', 'Obiad', '11:30 - 13:30', 2),
|
('mittagessen', 'Mittagessen', '11:30 - 13:30', 2),
|
||||||
('mittagessen_veg', 'Mittagessen vegetarisch', 'Lunch (vegetarian)', 'Obiad (wegetariański)', '11:30 - 13:30', 3),
|
('mittagessen_veg', 'Mittagessen vegetarisch', '11:30 - 13:30', 3),
|
||||||
('suppe', 'Suppe', 'Soup', 'Zupa', '11:30 - 13:30', 4);
|
('suppe', 'Suppe', '11:30 - 13:30', 4);
|
||||||
|
|
||||||
|
insert into section_translations (section_key, language_code, name) values
|
||||||
|
('brotzeit', 'en', 'Breakfast'),
|
||||||
|
('brotzeit', 'pl', 'Śniadanie'),
|
||||||
|
('mittagessen', 'en', 'Lunch'),
|
||||||
|
('mittagessen', 'pl', 'Obiad'),
|
||||||
|
('mittagessen_veg', 'en', 'Lunch (vegetarian)'),
|
||||||
|
('mittagessen_veg', 'pl', 'Obiad (wegetariański)'),
|
||||||
|
('suppe', 'en', 'Soup'),
|
||||||
|
('suppe', 'pl', 'Zupa');
|
||||||
|
|
||||||
-- ---------- Allergene (EU-Kennzeichnung, Buchstaben) ----------
|
-- ---------- Allergene (EU-Kennzeichnung, Buchstaben) ----------
|
||||||
insert into allergens (code, name, name_en, name_pl, sort) values
|
insert into allergens (code, name, sort) values
|
||||||
('A', 'Glutenhaltiges Getreide', 'Cereals containing gluten', 'Zboża zawierające gluten', 1),
|
('A', 'Glutenhaltiges Getreide', 1),
|
||||||
('B', 'Krebstiere', 'Crustaceans', 'Skorupiaki', 2),
|
('B', 'Krebstiere', 2),
|
||||||
('C', 'Eier', 'Eggs', 'Jaja', 3),
|
('C', 'Eier', 3),
|
||||||
('D', 'Fisch', 'Fish', 'Ryby', 4),
|
('D', 'Fisch', 4),
|
||||||
('E', 'Erdnüsse', 'Peanuts', 'Orzeszki ziemne', 5),
|
('E', 'Erdnüsse', 5),
|
||||||
('F', 'Sojabohnen', 'Soybeans', 'Soja', 6),
|
('F', 'Sojabohnen', 6),
|
||||||
('G', 'Milch (einschließlich Laktose)', 'Milk (including lactose)', 'Mleko (w tym laktoza)', 7),
|
('G', 'Milch (einschließlich Laktose)', 7),
|
||||||
('H', 'Schalenfrüchte (Nüsse)', 'Tree nuts', 'Orzechy', 8),
|
('H', 'Schalenfrüchte (Nüsse)', 8),
|
||||||
('I', 'Sellerie', 'Celery', 'Seler', 9),
|
('I', 'Sellerie', 9),
|
||||||
('J', 'Senf', 'Mustard', 'Gorczyca', 10),
|
('J', 'Senf', 10),
|
||||||
('K', 'Sesamsamen', 'Sesame seeds', 'Nasiona sezamu', 11),
|
('K', 'Sesamsamen', 11),
|
||||||
('L', 'Schwefeldioxid und Sulfite', 'Sulphur dioxide and sulphites', 'Dwutlenek siarki i siarczyny', 12),
|
('L', 'Schwefeldioxid und Sulfite', 12),
|
||||||
('M', 'Lupinen', 'Lupin', 'Łubin', 13),
|
('M', 'Lupinen', 13),
|
||||||
('N', 'Weichtiere', 'Molluscs', 'Mięczaki', 14);
|
('N', 'Weichtiere', 14);
|
||||||
|
|
||||||
|
insert into allergen_translations (allergen_code, language_code, name) values
|
||||||
|
('A', 'en', 'Cereals containing gluten'), ('A', 'pl', 'Zboża zawierające gluten'),
|
||||||
|
('B', 'en', 'Crustaceans'), ('B', 'pl', 'Skorupiaki'),
|
||||||
|
('C', 'en', 'Eggs'), ('C', 'pl', 'Jaja'),
|
||||||
|
('D', 'en', 'Fish'), ('D', 'pl', 'Ryby'),
|
||||||
|
('E', 'en', 'Peanuts'), ('E', 'pl', 'Orzeszki ziemne'),
|
||||||
|
('F', 'en', 'Soybeans'), ('F', 'pl', 'Soja'),
|
||||||
|
('G', 'en', 'Milk (including lactose)'), ('G', 'pl', 'Mleko (w tym laktoza)'),
|
||||||
|
('H', 'en', 'Tree nuts'), ('H', 'pl', 'Orzechy'),
|
||||||
|
('I', 'en', 'Celery'), ('I', 'pl', 'Seler'),
|
||||||
|
('J', 'en', 'Mustard'), ('J', 'pl', 'Gorczyca'),
|
||||||
|
('K', 'en', 'Sesame seeds'), ('K', 'pl', 'Nasiona sezamu'),
|
||||||
|
('L', 'en', 'Sulphur dioxide and sulphites'), ('L', 'pl', 'Dwutlenek siarki i siarczyny'),
|
||||||
|
('M', 'en', 'Lupin'), ('M', 'pl', 'Łubin'),
|
||||||
|
('N', 'en', 'Molluscs'), ('N', 'pl', 'Mięczaki');
|
||||||
|
|
||||||
-- ---------- Zusatzstoffe (Ziffern) ----------
|
-- ---------- Zusatzstoffe (Ziffern) ----------
|
||||||
insert into additives (code, name, name_en, name_pl, sort) values
|
insert into additives (code, name, sort) values
|
||||||
('1', 'mit Farbstoff', 'with colouring', 'z barwnikiem', 1),
|
('1', 'mit Farbstoff', 1),
|
||||||
('2', 'mit Konservierungsstoff', 'with preservative', 'z konserwantem', 2),
|
('2', 'mit Konservierungsstoff', 2),
|
||||||
('3', 'mit Antioxidationsmittel', 'with antioxidant', 'z przeciwutleniaczem', 3),
|
('3', 'mit Antioxidationsmittel', 3),
|
||||||
('4', 'mit Geschmacksverstärker', 'with flavour enhancer', 'ze wzmacniaczem smaku', 4),
|
('4', 'mit Geschmacksverstärker', 4),
|
||||||
('5', 'geschwefelt', 'sulphured', 'siarkowany', 5),
|
('5', 'geschwefelt', 5),
|
||||||
('6', 'geschwärzt', 'blackened', 'barwiony na czarno', 6),
|
('6', 'geschwärzt', 6),
|
||||||
('7', 'gewachst', 'waxed', 'woskowany', 7),
|
('7', 'gewachst', 7),
|
||||||
('8', 'mit Phosphat', 'with phosphate', 'z fosforanem', 8),
|
('8', 'mit Phosphat', 8),
|
||||||
('9', 'mit Süßungsmittel(n)', 'with sweetener(s)', 'ze słodzikiem/słodzikami', 9),
|
('9', 'mit Süßungsmittel(n)', 9),
|
||||||
('10', 'enthält eine Phenylalaninquelle', 'contains a source of phenylalanine', 'zawiera źródło fenyloalaniny', 10),
|
('10', 'enthält eine Phenylalaninquelle', 10),
|
||||||
('11', 'koffeinhaltig', 'caffeinated', 'zawiera kofeinę', 11);
|
('11', 'koffeinhaltig', 11);
|
||||||
|
|
||||||
|
insert into additive_translations (additive_code, language_code, name) values
|
||||||
|
('1', 'en', 'with colouring'), ('1', 'pl', 'z barwnikiem'),
|
||||||
|
('2', 'en', 'with preservative'), ('2', 'pl', 'z konserwantem'),
|
||||||
|
('3', 'en', 'with antioxidant'), ('3', 'pl', 'z przeciwutleniaczem'),
|
||||||
|
('4', 'en', 'with flavour enhancer'), ('4', 'pl', 'ze wzmacniaczem smaku'),
|
||||||
|
('5', 'en', 'sulphured'), ('5', 'pl', 'siarkowany'),
|
||||||
|
('6', 'en', 'blackened'), ('6', 'pl', 'barwiony na czarno'),
|
||||||
|
('7', 'en', 'waxed'), ('7', 'pl', 'woskowany'),
|
||||||
|
('8', 'en', 'with phosphate'), ('8', 'pl', 'z fosforanem'),
|
||||||
|
('9', 'en', 'with sweetener(s)'), ('9', 'pl', 'ze słodzikiem/słodzikami'),
|
||||||
|
('10', 'en', 'contains a source of phenylalanine'), ('10', 'pl', 'zawiera źródło fenyloalaniny'),
|
||||||
|
('11', 'en', 'caffeinated'), ('11', 'pl', 'zawiera kofeinę');
|
||||||
|
|
||||||
-- ---------- Testgerichte ----------
|
-- ---------- Testgerichte ----------
|
||||||
insert into dishes (name, price) values
|
insert into dishes (name, price) values
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
}
|
||||||
+56
-27
@@ -1,39 +1,70 @@
|
|||||||
import "server-only";
|
import "server-only";
|
||||||
import { query, queryOne, withTransaction } from "@/lib/db";
|
import { query, queryOne } from "@/lib/db";
|
||||||
|
import {
|
||||||
|
loadTranslations,
|
||||||
|
loadTranslationsFor,
|
||||||
|
replaceTranslations,
|
||||||
|
type TranslationTable,
|
||||||
|
} from "./translations";
|
||||||
import type { CodeItem } from "@/lib/types";
|
import type { CodeItem } from "@/lib/types";
|
||||||
|
|
||||||
// "allergens"/"additives" sind identisch aufgebaute Tabellen (Kürzel +
|
// "allergens"/"additives" sind identisch aufgebaute Tabellen (Kürzel +
|
||||||
// Bezeichnung in DE/EN/PL). Tabellenname kommt nie aus Nutzereingaben,
|
// Bezeichnung in Deutsch, Übersetzungen in der jeweiligen
|
||||||
|
// *_translations-Tabelle). Tabellenname kommt nie aus Nutzereingaben,
|
||||||
// sondern immer aus dem literal getypten Parameter unten – sicher für
|
// sondern immer aus dem literal getypten Parameter unten – sicher für
|
||||||
// String-Interpolation in der Query.
|
// String-Interpolation in der Query.
|
||||||
export type CodeTable = "allergens" | "additives";
|
export type CodeTable = "allergens" | "additives";
|
||||||
|
|
||||||
|
const TRANSLATION_TABLE: Record<CodeTable, TranslationTable> = {
|
||||||
|
allergens: "allergen_translations",
|
||||||
|
additives: "additive_translations",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface CodeRow {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
sort: number;
|
||||||
|
}
|
||||||
|
|
||||||
export async function listCodes(table: CodeTable): Promise<CodeItem[]> {
|
export async function listCodes(table: CodeTable): Promise<CodeItem[]> {
|
||||||
return query<CodeItem>(`select * from ${table} order by sort`);
|
const rows = await query<CodeRow>(`select code, name, sort from ${table} order by sort`);
|
||||||
|
const translations = await loadTranslations(TRANSLATION_TABLE[table]);
|
||||||
|
return rows.map((r) => ({ ...r, translations: translations.get(r.code) ?? {} }));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createCode(
|
export async function createCode(
|
||||||
table: CodeTable,
|
table: CodeTable,
|
||||||
input: { code: string; name: string; name_en: string | null; name_pl: string | null }
|
input: { code: string; name: string; translations?: Record<string, string | null> }
|
||||||
): Promise<CodeItem> {
|
): Promise<CodeItem> {
|
||||||
const maxRow = await queryOne<{ sort: number }>(
|
const maxRow = await queryOne<{ sort: number }>(
|
||||||
`select sort from ${table} order by sort desc limit 1`
|
`select sort from ${table} order by sort desc limit 1`
|
||||||
);
|
);
|
||||||
return (await queryOne<CodeItem>(
|
const row = (await queryOne<CodeRow>(
|
||||||
`insert into ${table} (code, name, name_en, name_pl, sort) values ($1, $2, $3, $4, $5) returning *`,
|
`insert into ${table} (code, name, sort) values ($1, $2, $3) returning code, name, sort`,
|
||||||
[input.code, input.name, input.name_en, input.name_pl, (maxRow?.sort ?? 0) + 1]
|
[input.code, input.name, (maxRow?.sort ?? 0) + 1]
|
||||||
))!;
|
))!;
|
||||||
|
if (input.translations) {
|
||||||
|
await replaceTranslations(TRANSLATION_TABLE[table], row.code, input.translations);
|
||||||
|
}
|
||||||
|
const translations = await loadTranslationsFor(TRANSLATION_TABLE[table], [row.code]);
|
||||||
|
return { ...row, translations: translations.get(row.code) ?? {} };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateCode(
|
export async function updateCode(
|
||||||
table: CodeTable,
|
table: CodeTable,
|
||||||
originalCode: string,
|
originalCode: string,
|
||||||
input: { code: string; name: string; name_en: string | null; name_pl: string | null }
|
input: { code: string; name: string; translations?: Record<string, string | null> }
|
||||||
): Promise<CodeItem | null> {
|
): Promise<CodeItem | null> {
|
||||||
return queryOne<CodeItem>(
|
const row = await queryOne<CodeRow>(
|
||||||
`update ${table} set code = $1, name = $2, name_en = $3, name_pl = $4 where code = $5 returning *`,
|
`update ${table} set code = $1, name = $2 where code = $3 returning code, name, sort`,
|
||||||
[input.code, input.name, input.name_en, input.name_pl, originalCode]
|
[input.code, input.name, originalCode]
|
||||||
);
|
);
|
||||||
|
if (!row) return null;
|
||||||
|
if (input.translations) {
|
||||||
|
await replaceTranslations(TRANSLATION_TABLE[table], row.code, input.translations);
|
||||||
|
}
|
||||||
|
const translations = await loadTranslationsFor(TRANSLATION_TABLE[table], [row.code]);
|
||||||
|
return { ...row, translations: translations.get(row.code) ?? {} };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteCode(table: CodeTable, code: string): Promise<void> {
|
export async function deleteCode(table: CodeTable, code: string): Promise<void> {
|
||||||
@@ -43,8 +74,7 @@ export async function deleteCode(table: CodeTable, code: string): Promise<void>
|
|||||||
export interface CodeImportRow {
|
export interface CodeImportRow {
|
||||||
code: string;
|
code: string;
|
||||||
name: string;
|
name: string;
|
||||||
name_en: string | null;
|
translations: Record<string, string>;
|
||||||
name_pl: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** CSV-Import: legt neue Kürzel an, bestehende (gleicher Code) werden
|
/** CSV-Import: legt neue Kürzel an, bestehende (gleicher Code) werden
|
||||||
@@ -54,32 +84,31 @@ export async function importCodes(
|
|||||||
table: CodeTable,
|
table: CodeTable,
|
||||||
rows: CodeImportRow[]
|
rows: CodeImportRow[]
|
||||||
): Promise<{ created: number; updated: number }> {
|
): Promise<{ created: number; updated: number }> {
|
||||||
return withTransaction(async (client) => {
|
const existing = await query<{ code: string }>(`select code from ${table}`);
|
||||||
const existing = await client.query<{ code: string }>(`select code from ${table}`);
|
const known = new Set(existing.map((r) => r.code));
|
||||||
const known = new Set(existing.rows.map((r) => r.code));
|
const maxRow = await queryOne<{ sort: number }>(
|
||||||
const maxRow = await client.query<{ sort: number }>(
|
|
||||||
`select coalesce(max(sort), 0) as sort from ${table}`
|
`select coalesce(max(sort), 0) as sort from ${table}`
|
||||||
);
|
);
|
||||||
let nextSort = (maxRow.rows[0]?.sort ?? 0) + 1;
|
let nextSort = (maxRow?.sort ?? 0) + 1;
|
||||||
let created = 0;
|
let created = 0;
|
||||||
let updated = 0;
|
let updated = 0;
|
||||||
|
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
if (known.has(row.code)) {
|
if (known.has(row.code)) {
|
||||||
await client.query(
|
await query(`update ${table} set name = $1 where code = $2`, [row.name, row.code]);
|
||||||
`update ${table} set name = $1, name_en = $2, name_pl = $3 where code = $4`,
|
|
||||||
[row.name, row.name_en, row.name_pl, row.code]
|
|
||||||
);
|
|
||||||
updated++;
|
updated++;
|
||||||
} else {
|
} else {
|
||||||
await client.query(
|
await query(`insert into ${table} (code, name, sort) values ($1, $2, $3)`, [
|
||||||
`insert into ${table} (code, name, name_en, name_pl, sort) values ($1, $2, $3, $4, $5)`,
|
row.code,
|
||||||
[row.code, row.name, row.name_en, row.name_pl, nextSort++]
|
row.name,
|
||||||
);
|
nextSort++,
|
||||||
|
]);
|
||||||
known.add(row.code);
|
known.add(row.code);
|
||||||
created++;
|
created++;
|
||||||
}
|
}
|
||||||
|
if (Object.keys(row.translations).length > 0) {
|
||||||
|
await replaceTranslations(TRANSLATION_TABLE[table], row.code, row.translations);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return { created, updated };
|
return { created, updated };
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import "server-only";
|
import "server-only";
|
||||||
import { query } from "@/lib/db";
|
import { query } from "@/lib/db";
|
||||||
|
import { loadTranslationsFor } from "./translations";
|
||||||
import type { MenuEntry } from "@/lib/types";
|
import type { MenuEntry } from "@/lib/types";
|
||||||
|
|
||||||
interface MenuEntryRow {
|
interface MenuEntryRow {
|
||||||
@@ -9,8 +10,6 @@ interface MenuEntryRow {
|
|||||||
position: number;
|
position: number;
|
||||||
dish_id: string;
|
dish_id: string;
|
||||||
name: string;
|
name: string;
|
||||||
name_en: string | null;
|
|
||||||
name_pl: string | null;
|
|
||||||
price: string;
|
price: string;
|
||||||
allergens: string[];
|
allergens: string[];
|
||||||
additives: string[];
|
additives: string[];
|
||||||
@@ -22,7 +21,7 @@ interface MenuEntryRow {
|
|||||||
export async function loadMenuEntries(fromISO: string, toISO: string): Promise<MenuEntry[]> {
|
export async function loadMenuEntries(fromISO: string, toISO: string): Promise<MenuEntry[]> {
|
||||||
const rows = await query<MenuEntryRow>(
|
const rows = await query<MenuEntryRow>(
|
||||||
`select me.id, me.menu_date, me.section_key, me.position,
|
`select me.id, me.menu_date, me.section_key, me.position,
|
||||||
d.id as dish_id, d.name, d.name_en, d.name_pl, d.price, d.allergens, d.additives
|
d.id as dish_id, d.name, d.price, d.allergens, d.additives
|
||||||
from menu_entries me
|
from menu_entries me
|
||||||
join dishes d on d.id = me.dish_id
|
join dishes d on d.id = me.dish_id
|
||||||
where me.menu_date between $1 and $2
|
where me.menu_date between $1 and $2
|
||||||
@@ -30,6 +29,9 @@ export async function loadMenuEntries(fromISO: string, toISO: string): Promise<M
|
|||||||
[fromISO, toISO]
|
[fromISO, toISO]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const dishIds = [...new Set(rows.map((r) => r.dish_id))];
|
||||||
|
const translations = await loadTranslationsFor("dish_translations", dishIds);
|
||||||
|
|
||||||
return rows.map((r) => ({
|
return rows.map((r) => ({
|
||||||
id: r.id,
|
id: r.id,
|
||||||
menu_date: r.menu_date,
|
menu_date: r.menu_date,
|
||||||
@@ -38,8 +40,7 @@ export async function loadMenuEntries(fromISO: string, toISO: string): Promise<M
|
|||||||
dish: {
|
dish: {
|
||||||
id: r.dish_id,
|
id: r.dish_id,
|
||||||
name: r.name,
|
name: r.name,
|
||||||
name_en: r.name_en,
|
translations: translations.get(r.dish_id) ?? {},
|
||||||
name_pl: r.name_pl,
|
|
||||||
price: Number(r.price),
|
price: Number(r.price),
|
||||||
allergens: r.allergens,
|
allergens: r.allergens,
|
||||||
additives: r.additives,
|
additives: r.additives,
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import "server-only";
|
||||||
|
import { query } from "@/lib/db";
|
||||||
|
|
||||||
|
// Gemeinsame Zugriffsschicht für die vier Übersetzungstabellen aus
|
||||||
|
// db/migrations/0006_content_translations.sql. Die Wert-Spalte heißt
|
||||||
|
// in allen vieren bewusst einheitlich "name" (auch bei
|
||||||
|
// section_translations, obwohl sections.label selbst anders heißt) –
|
||||||
|
// vereinfacht diese generischen Helfer, statt zusätzlich eine Spalten-
|
||||||
|
// Namens-Zuordnung pflegen zu müssen.
|
||||||
|
export type TranslationTable =
|
||||||
|
| "dish_translations"
|
||||||
|
| "section_translations"
|
||||||
|
| "allergen_translations"
|
||||||
|
| "additive_translations"
|
||||||
|
| "closed_day_translations";
|
||||||
|
|
||||||
|
const ID_COLUMN: Record<TranslationTable, string> = {
|
||||||
|
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. */
|
||||||
|
export async function loadTranslations(
|
||||||
|
table: TranslationTable
|
||||||
|
): Promise<Map<string, Record<string, string>>> {
|
||||||
|
const idCol = ID_COLUMN[table];
|
||||||
|
const rows = await query<{ id: string; language_code: string; name: string }>(
|
||||||
|
`select ${idCol} as id, language_code, name from ${table}`
|
||||||
|
);
|
||||||
|
return groupByParent(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Übersetzungen nur für eine Teilmenge von Eltern-IDs laden (z. B. die
|
||||||
|
* Gerichte des aktuell angezeigten Wochenplan-Zeitraums). */
|
||||||
|
export async function loadTranslationsFor(
|
||||||
|
table: TranslationTable,
|
||||||
|
ids: string[]
|
||||||
|
): Promise<Map<string, Record<string, string>>> {
|
||||||
|
if (ids.length === 0) return new Map();
|
||||||
|
const idCol = ID_COLUMN[table];
|
||||||
|
const rows = await query<{ id: string; language_code: string; name: string }>(
|
||||||
|
`select ${idCol} as id, language_code, name from ${table} where ${idCol} = any($1)`,
|
||||||
|
[ids]
|
||||||
|
);
|
||||||
|
return groupByParent(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupByParent(
|
||||||
|
rows: { id: string; language_code: string; name: string }[]
|
||||||
|
): Map<string, Record<string, string>> {
|
||||||
|
const map = new Map<string, Record<string, string>>();
|
||||||
|
for (const r of rows) {
|
||||||
|
if (!map.has(r.id)) map.set(r.id, {});
|
||||||
|
map.get(r.id)![r.language_code] = r.name;
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Übersetzungen eines Eintrags für die übergebenen Sprachen setzen –
|
||||||
|
* ein leerer/nur-Leerzeichen-Wert löscht die Zeile für diese Sprache
|
||||||
|
* wieder (z. B. wenn ein Formularfeld geleert wird), statt eine leere
|
||||||
|
* Übersetzung zu speichern. */
|
||||||
|
export async function replaceTranslations(
|
||||||
|
table: TranslationTable,
|
||||||
|
id: string,
|
||||||
|
translations: Record<string, string | null | undefined>
|
||||||
|
): Promise<void> {
|
||||||
|
const idCol = ID_COLUMN[table];
|
||||||
|
for (const [languageCode, rawValue] of Object.entries(translations)) {
|
||||||
|
const value = rawValue?.trim();
|
||||||
|
if (!value) {
|
||||||
|
await query(`delete from ${table} where ${idCol} = $1 and language_code = $2`, [
|
||||||
|
id,
|
||||||
|
languageCode,
|
||||||
|
]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await query(
|
||||||
|
`insert into ${table} (${idCol}, language_code, name) values ($1, $2, $3)
|
||||||
|
on conflict (${idCol}, language_code) do update set name = excluded.name`,
|
||||||
|
[id, languageCode, value]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,14 +88,17 @@ 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",
|
||||||
newDish: "Neues Gericht",
|
newDish: "Neues Gericht",
|
||||||
editDish: "Gericht bearbeiten",
|
editDish: "Gericht bearbeiten",
|
||||||
nameLabel: "Bezeichnung",
|
nameLabel: "Bezeichnung",
|
||||||
nameEnLabel: "Name (Englisch)",
|
|
||||||
namePlLabel: "Name (Polnisch)",
|
|
||||||
autoTranslate: "Automatisch übersetzen",
|
autoTranslate: "Automatisch übersetzen",
|
||||||
translating: "Übersetze…",
|
translating: "Übersetze…",
|
||||||
translated: "Übersetzt.",
|
translated: "Übersetzt.",
|
||||||
@@ -150,8 +154,6 @@ const de = {
|
|||||||
newSection: "Neue Essenszeit",
|
newSection: "Neue Essenszeit",
|
||||||
editSection: "Essenszeit bearbeiten",
|
editSection: "Essenszeit bearbeiten",
|
||||||
nameLabel: "Bezeichnung",
|
nameLabel: "Bezeichnung",
|
||||||
nameEnLabel: "Bezeichnung (Englisch)",
|
|
||||||
namePlLabel: "Bezeichnung (Polnisch)",
|
|
||||||
timeFrom: "Von",
|
timeFrom: "Von",
|
||||||
timeTo: "Bis",
|
timeTo: "Bis",
|
||||||
moveUp: "Nach oben",
|
moveUp: "Nach oben",
|
||||||
|
|||||||
+5
-2
@@ -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
|
||||||
|
|||||||
+31
-8
@@ -12,8 +12,9 @@ export interface Profile {
|
|||||||
export interface Section {
|
export interface Section {
|
||||||
key: string;
|
key: string;
|
||||||
label: string;
|
label: string;
|
||||||
label_en: string | null;
|
// Sprachcode (z. B. "en", "fr") -> übersetzter Name. Enthält nie "de" -
|
||||||
label_pl: string | null;
|
// Deutsch ist immer die "label"-Spalte selbst.
|
||||||
|
translations: Record<string, string>;
|
||||||
time_range: string;
|
time_range: string;
|
||||||
sort: number;
|
sort: number;
|
||||||
}
|
}
|
||||||
@@ -21,8 +22,7 @@ export interface Section {
|
|||||||
export interface Dish {
|
export interface Dish {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
name_en: string | null;
|
translations: Record<string, string>;
|
||||||
name_pl: string | null;
|
|
||||||
price: number;
|
price: number;
|
||||||
allergens: string[];
|
allergens: string[];
|
||||||
additives: string[];
|
additives: string[];
|
||||||
@@ -39,8 +39,7 @@ export interface MenuEntry {
|
|||||||
export interface CodeItem {
|
export interface CodeItem {
|
||||||
code: string;
|
code: string;
|
||||||
name: string;
|
name: string;
|
||||||
name_en: string | null;
|
translations: Record<string, string>;
|
||||||
name_pl: string | null;
|
|
||||||
sort: number;
|
sort: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,12 +50,36 @@ 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: { name?: string; label?: string; name_en?: string | null; name_pl?: string | null; label_en?: string | null; label_pl?: string | null },
|
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<string, string>;
|
||||||
|
// Ältere, feste Form – wird u. a. noch für den Hinweistext in den
|
||||||
|
// Design-Einstellungen genutzt (settings.labeling_notice_en/_pl),
|
||||||
|
// die kein Übersetzungstabellen-Konzept haben.
|
||||||
|
name_en?: string | null;
|
||||||
|
name_pl?: string | null;
|
||||||
|
label_en?: string | null;
|
||||||
|
label_pl?: string | null;
|
||||||
|
},
|
||||||
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 (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;
|
||||||
if (locale === "pl") return item.name_pl || item.label_pl || de;
|
if (locale === "pl") return item.name_pl || item.label_pl || de;
|
||||||
return de;
|
return de;
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "canteen-menu",
|
"name": "canteen-menu",
|
||||||
"version": "1.10.0",
|
"version": "1.12.0",
|
||||||
"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",
|
||||||
|
|||||||
Reference in New Issue
Block a user