Merge feature/v1.11.0: Speiseplan-Inhalte variabel mehrsprachig
This commit is contained in:
@@ -4,6 +4,40 @@ Alle nennenswerten Änderungen an diesem Projekt werden hier dokumentiert.
|
||||
Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.1.0/),
|
||||
Versionierung nach [Semantic Versioning](https://semver.org/lang/de/).
|
||||
|
||||
## [1.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
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import { listCodes } from "@/lib/db/code-lists";
|
||||
import { listActiveLanguageCodes } from "@/lib/db/languages";
|
||||
import { authErrorResponse, requireRole } from "@/lib/auth/session";
|
||||
import { toCsv } from "@/lib/csv";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
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 = [
|
||||
["code", "name", "name_en", "name_pl"],
|
||||
...items.map((i) => [i.code, i.name, i.name_en ?? "", i.name_pl ?? ""]),
|
||||
["code", "name", ...langCodes.map((c) => `name_${c}`)],
|
||||
...items.map((i) => [i.code, i.name, ...langCodes.map((c) => i.translations[c] ?? "")]),
|
||||
];
|
||||
return new Response(toCsv(rows), {
|
||||
headers: {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { importCodes, type CodeImportRow } from "@/lib/db/code-lists";
|
||||
import { listActiveLanguageCodes } from "@/lib/db/languages";
|
||||
import { authErrorResponse, requireRole } from "@/lib/auth/session";
|
||||
import { isSameOrigin } from "@/lib/origin";
|
||||
import { parseCsvWithHeader } from "@/lib/csv";
|
||||
@@ -22,8 +23,10 @@ export async function POST(request: Request) {
|
||||
if (codeIdx === -1 || nameIdx === -1) {
|
||||
return NextResponse.json({ error: "missing_columns" }, { status: 400 });
|
||||
}
|
||||
const nameEnIdx = header.indexOf("name_en");
|
||||
const namePlIdx = header.indexOf("name_pl");
|
||||
const activeCodes = (await listActiveLanguageCodes()).filter((c) => c !== "de");
|
||||
const langCols = activeCodes
|
||||
.map((code) => ({ code, idx: header.indexOf(`name_${code}`) }))
|
||||
.filter((c) => c.idx !== -1);
|
||||
|
||||
const rows: CodeImportRow[] = [];
|
||||
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" });
|
||||
return;
|
||||
}
|
||||
rows.push({
|
||||
code,
|
||||
name,
|
||||
name_en: nameEnIdx >= 0 ? c[nameEnIdx]?.trim() || null : null,
|
||||
name_pl: namePlIdx >= 0 ? c[namePlIdx]?.trim() || null : null,
|
||||
});
|
||||
const translations: Record<string, string> = {};
|
||||
for (const { code: lang, idx } of langCols) {
|
||||
const value = c[idx]?.trim();
|
||||
if (value) translations[lang] = value;
|
||||
}
|
||||
rows.push({ code, name, translations });
|
||||
});
|
||||
|
||||
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 { listActiveLanguageCodes } from "@/lib/db/languages";
|
||||
import { authErrorResponse, requireRole } from "@/lib/auth/session";
|
||||
import { toCsv } from "@/lib/csv";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
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 = [
|
||||
["code", "name", "name_en", "name_pl"],
|
||||
...items.map((i) => [i.code, i.name, i.name_en ?? "", i.name_pl ?? ""]),
|
||||
["code", "name", ...langCodes.map((c) => `name_${c}`)],
|
||||
...items.map((i) => [i.code, i.name, ...langCodes.map((c) => i.translations[c] ?? "")]),
|
||||
];
|
||||
return new Response(toCsv(rows), {
|
||||
headers: {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { importCodes, type CodeImportRow } from "@/lib/db/code-lists";
|
||||
import { listActiveLanguageCodes } from "@/lib/db/languages";
|
||||
import { authErrorResponse, requireRole } from "@/lib/auth/session";
|
||||
import { isSameOrigin } from "@/lib/origin";
|
||||
import { parseCsvWithHeader } from "@/lib/csv";
|
||||
@@ -22,8 +23,10 @@ export async function POST(request: Request) {
|
||||
if (codeIdx === -1 || nameIdx === -1) {
|
||||
return NextResponse.json({ error: "missing_columns" }, { status: 400 });
|
||||
}
|
||||
const nameEnIdx = header.indexOf("name_en");
|
||||
const namePlIdx = header.indexOf("name_pl");
|
||||
const activeCodes = (await listActiveLanguageCodes()).filter((c) => c !== "de");
|
||||
const langCols = activeCodes
|
||||
.map((code) => ({ code, idx: header.indexOf(`name_${code}`) }))
|
||||
.filter((c) => c.idx !== -1);
|
||||
|
||||
const rows: CodeImportRow[] = [];
|
||||
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" });
|
||||
return;
|
||||
}
|
||||
rows.push({
|
||||
code,
|
||||
name,
|
||||
name_en: nameEnIdx >= 0 ? c[nameEnIdx]?.trim() || null : null,
|
||||
name_pl: namePlIdx >= 0 ? c[namePlIdx]?.trim() || null : null,
|
||||
});
|
||||
const translations: Record<string, string> = {};
|
||||
for (const { code: lang, idx } of langCols) {
|
||||
const value = c[idx]?.trim();
|
||||
if (value) translations[lang] = value;
|
||||
}
|
||||
rows.push({ code, name, translations });
|
||||
});
|
||||
|
||||
const result = rows.length > 0 ? await importCodes("allergens", rows) : { created: 0, updated: 0 };
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { query, queryOne } from "@/lib/db";
|
||||
import { loadTranslationsFor, replaceTranslations } from "@/lib/db/translations";
|
||||
import { authErrorResponse, requireRole } from "@/lib/auth/session";
|
||||
import { isSameOrigin } from "@/lib/origin";
|
||||
import type { Dish } from "@/lib/types";
|
||||
|
||||
interface DishRow {
|
||||
id: string;
|
||||
name: string;
|
||||
price: string;
|
||||
allergens: string[];
|
||||
additives: string[];
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
@@ -16,19 +25,26 @@ export async function PATCH(
|
||||
const { id } = await params;
|
||||
const body = (await request.json()) as {
|
||||
name: string;
|
||||
name_en: string | null;
|
||||
name_pl: string | null;
|
||||
price: number;
|
||||
allergens: string[];
|
||||
additives: string[];
|
||||
translations?: Record<string, string | null>;
|
||||
};
|
||||
const dish = await queryOne<Dish>(
|
||||
`update dishes set name = $1, name_en = $2, name_pl = $3, price = $4,
|
||||
allergens = $5, additives = $6
|
||||
where id = $7 returning *`,
|
||||
[body.name, body.name_en, body.name_pl, body.price, body.allergens, body.additives, id]
|
||||
const row = await queryOne<DishRow>(
|
||||
`update dishes set name = $1, price = $2, allergens = $3, additives = $4
|
||||
where id = $5 returning id, name, price, allergens, additives`,
|
||||
[body.name, 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);
|
||||
} catch (err) {
|
||||
const res = authErrorResponse(err);
|
||||
|
||||
@@ -1,19 +1,35 @@
|
||||
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 { 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() {
|
||||
try {
|
||||
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 = [
|
||||
["name", "name_en", "name_pl", "price", "allergens", "additives"],
|
||||
["name", ...langCodes.map((c) => `name_${c}`), "price", "allergens", "additives"],
|
||||
...dishes.map((d) => [
|
||||
d.name,
|
||||
d.name_en ?? "",
|
||||
d.name_pl ?? "",
|
||||
String(d.price),
|
||||
...langCodes.map((c) => translations.get(d.id)?.[c] ?? ""),
|
||||
d.price,
|
||||
(d.allergens ?? []).join(";"),
|
||||
(d.additives ?? []).join(";"),
|
||||
]),
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
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 { isSameOrigin } from "@/lib/origin";
|
||||
import { parseCsvWithHeader, splitCodes } from "@/lib/csv";
|
||||
@@ -7,8 +9,7 @@ import { parseCsvWithHeader, splitCodes } from "@/lib/csv";
|
||||
interface ImportRow {
|
||||
rowNum: number;
|
||||
name: string;
|
||||
name_en: string | null;
|
||||
name_pl: string | null;
|
||||
translations: Record<string, string>;
|
||||
price: number;
|
||||
allergens: string[];
|
||||
additives: string[];
|
||||
@@ -32,8 +33,10 @@ export async function POST(request: Request) {
|
||||
if (nameIdx === -1 || priceIdx === -1) {
|
||||
return NextResponse.json({ error: "missing_columns" }, { status: 400 });
|
||||
}
|
||||
const nameEnIdx = header.indexOf("name_en");
|
||||
const namePlIdx = header.indexOf("name_pl");
|
||||
const activeCodes = (await listActiveLanguageCodes()).filter((c) => c !== "de");
|
||||
const langCols = activeCodes
|
||||
.map((code) => ({ code, idx: header.indexOf(`name_${code}`) }))
|
||||
.filter((c) => c.idx !== -1);
|
||||
const allergensIdx = header.indexOf("allergens");
|
||||
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] ?? ""}“` });
|
||||
return;
|
||||
}
|
||||
const translations: Record<string, string> = {};
|
||||
for (const { code, idx } of langCols) {
|
||||
const value = c[idx]?.trim();
|
||||
if (value) translations[code] = value;
|
||||
}
|
||||
rows.push({
|
||||
rowNum,
|
||||
name,
|
||||
name_en: nameEnIdx >= 0 ? c[nameEnIdx]?.trim() || null : null,
|
||||
name_pl: namePlIdx >= 0 ? c[namePlIdx]?.trim() || null : null,
|
||||
translations,
|
||||
price,
|
||||
allergens: allergensIdx >= 0 ? splitCodes(c[allergensIdx]) : [],
|
||||
additives: additivesIdx >= 0 ? splitCodes(c[additivesIdx]) : [],
|
||||
@@ -65,6 +72,7 @@ export async function POST(request: Request) {
|
||||
|
||||
let created = 0;
|
||||
let updated = 0;
|
||||
const savedTranslations: { id: string; translations: Record<string, string> }[] = [];
|
||||
if (rows.length > 0) {
|
||||
await withTransaction(async (client) => {
|
||||
for (const row of rows) {
|
||||
@@ -84,21 +92,27 @@ export async function POST(request: Request) {
|
||||
}
|
||||
if (existing.rows.length === 1) {
|
||||
await client.query(
|
||||
`update dishes set name_en = $1, name_pl = $2, price = $3, allergens = $4, additives = $5
|
||||
where id = $6`,
|
||||
[row.name_en, row.name_pl, row.price, row.allergens, row.additives, existing.rows[0].id]
|
||||
`update dishes set price = $1, allergens = $2, additives = $3 where id = $4`,
|
||||
[row.price, row.allergens, row.additives, existing.rows[0].id]
|
||||
);
|
||||
savedTranslations.push({ id: existing.rows[0].id, translations: row.translations });
|
||||
updated++;
|
||||
} else {
|
||||
await client.query(
|
||||
`insert into dishes (name, name_en, name_pl, price, allergens, additives)
|
||||
values ($1, $2, $3, $4, $5, $6)`,
|
||||
[row.name, row.name_en, row.name_pl, row.price, row.allergens, row.additives]
|
||||
const inserted = await client.query<{ id: string }>(
|
||||
`insert into dishes (name, price, allergens, additives)
|
||||
values ($1, $2, $3, $4) returning id`,
|
||||
[row.name, row.price, row.allergens, row.additives]
|
||||
);
|
||||
savedTranslations.push({ id: inserted.rows[0].id, translations: row.translations });
|
||||
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 });
|
||||
|
||||
+35
-9
@@ -1,12 +1,35 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { query, queryOne } from "@/lib/db";
|
||||
import { loadTranslationsFor, replaceTranslations } from "@/lib/db/translations";
|
||||
import { authErrorResponse, requireRole } from "@/lib/auth/session";
|
||||
import { isSameOrigin } from "@/lib/origin";
|
||||
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() {
|
||||
const dishes = await query<Dish>("select * from dishes order by name");
|
||||
return NextResponse.json(dishes);
|
||||
const rows = await query<DishRow>(
|
||||
"select id, name, price, allergens, additives from dishes order by name"
|
||||
);
|
||||
return NextResponse.json(await withTranslations(rows));
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
@@ -17,17 +40,20 @@ export async function POST(request: Request) {
|
||||
await requireRole(["staff", "admin"]);
|
||||
const body = (await request.json()) as {
|
||||
name: string;
|
||||
name_en: string | null;
|
||||
name_pl: string | null;
|
||||
price: number;
|
||||
allergens: string[];
|
||||
additives: string[];
|
||||
translations?: Record<string, string | null>;
|
||||
};
|
||||
const dish = await queryOne<Dish>(
|
||||
`insert into dishes (name, name_en, name_pl, price, allergens, additives)
|
||||
values ($1, $2, $3, $4, $5, $6) returning *`,
|
||||
[body.name, body.name_en, body.name_pl, body.price, body.allergens, body.additives]
|
||||
);
|
||||
const row = (await queryOne<DishRow>(
|
||||
`insert into dishes (name, price, allergens, additives)
|
||||
values ($1, $2, $3, $4) returning id, name, price, allergens, 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 });
|
||||
} catch (err) {
|
||||
const res = authErrorResponse(err);
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { query, queryOne } from "@/lib/db";
|
||||
import { loadTranslationsFor, replaceTranslations } from "@/lib/db/translations";
|
||||
import { authErrorResponse, requireRole } from "@/lib/auth/session";
|
||||
import { isSameOrigin } from "@/lib/origin";
|
||||
import type { Section } from "@/lib/types";
|
||||
|
||||
interface SectionRow {
|
||||
key: string;
|
||||
label: string;
|
||||
time_range: string;
|
||||
sort: number;
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ key: string }> }
|
||||
@@ -15,25 +23,37 @@ export async function PATCH(
|
||||
await requireRole(["staff", "admin"]);
|
||||
const { key } = await params;
|
||||
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 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) {
|
||||
values.push(body[col]);
|
||||
fields.push(`${col} = $${values.length}`);
|
||||
}
|
||||
}
|
||||
if (fields.length === 0) return NextResponse.json({ error: "empty_update" }, { status: 400 });
|
||||
values.push(key);
|
||||
|
||||
const section = await queryOne<Section>(
|
||||
`update sections set ${fields.join(", ")} where key = $${values.length} returning *`,
|
||||
values
|
||||
);
|
||||
if (!section) return NextResponse.json({ error: "not_found" }, { status: 404 });
|
||||
let row: SectionRow | null;
|
||||
if (fields.length > 0) {
|
||||
values.push(key);
|
||||
row = await queryOne<SectionRow>(
|
||||
`update sections set ${fields.join(", ")} where key = $${values.length}
|
||||
returning key, label, time_range, sort`,
|
||||
values
|
||||
);
|
||||
} 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);
|
||||
} catch (err) {
|
||||
const res = authErrorResponse(err);
|
||||
|
||||
@@ -1,12 +1,30 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { query, queryOne } from "@/lib/db";
|
||||
import { loadTranslationsFor, replaceTranslations } from "@/lib/db/translations";
|
||||
import { authErrorResponse, requireRole } from "@/lib/auth/session";
|
||||
import { isSameOrigin } from "@/lib/origin";
|
||||
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() {
|
||||
const sections = await query<Section>("select * from sections order by sort");
|
||||
return NextResponse.json(sections);
|
||||
const rows = await query<SectionRow>(
|
||||
"select key, label, time_range, sort from sections order by sort"
|
||||
);
|
||||
return NextResponse.json(await withTranslations(rows));
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
@@ -17,19 +35,22 @@ export async function POST(request: Request) {
|
||||
await requireRole(["staff", "admin"]);
|
||||
const body = (await request.json()) as {
|
||||
label: string;
|
||||
label_en: string | null;
|
||||
label_pl: string | null;
|
||||
time_range: string;
|
||||
translations?: Record<string, string | null>;
|
||||
};
|
||||
|
||||
const maxRow = await queryOne<{ sort: number }>(
|
||||
"select sort from sections order by sort desc limit 1"
|
||||
);
|
||||
const section = await queryOne<Section>(
|
||||
`insert into sections (label, label_en, label_pl, time_range, sort)
|
||||
values ($1, $2, $3, $4, $5) returning *`,
|
||||
[body.label, body.label_en, body.label_pl, body.time_range, (maxRow?.sort ?? 0) + 1]
|
||||
);
|
||||
const row = (await queryOne<SectionRow>(
|
||||
`insert into sections (label, time_range, sort)
|
||||
values ($1, $2, $3) returning key, label, time_range, sort`,
|
||||
[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 });
|
||||
} catch (err) {
|
||||
const res = authErrorResponse(err);
|
||||
|
||||
+43
-27
@@ -1,21 +1,31 @@
|
||||
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 { isSameOrigin } from "@/lib/origin";
|
||||
|
||||
// Übersetzt einen deutschen Text nach Englisch/Polnisch via DeepL und
|
||||
// schreibt das Ergebnis in die _en/_pl-Spalte der angegebenen Zeile.
|
||||
// Ohne DEEPL_API_KEY läuft die Route kontrolliert ins Leere (configured:
|
||||
// false), damit das UI die manuelle Eingabe anbieten kann.
|
||||
// Übersetzt einen deutschen Text via DeepL in alle aktiven, von DeepL
|
||||
// unterstützten Sprachen und schreibt das Ergebnis in die passende
|
||||
// *_translations-Tabelle. Ohne DEEPL_API_KEY läuft die Route kontrolliert
|
||||
// ins Leere (configured: false), damit das UI die manuelle Eingabe anbietet.
|
||||
|
||||
const ALLOWED_TABLES: Record<string, { idColumn: string; textColumn: string }> = {
|
||||
dishes: { idColumn: "id", textColumn: "name" },
|
||||
sections: { idColumn: "key", textColumn: "label" },
|
||||
allergens: { idColumn: "code", textColumn: "name" },
|
||||
additives: { idColumn: "code", textColumn: "name" },
|
||||
const ALLOWED_TABLES: Record<string, { idColumn: string; translationTable: TranslationTable }> = {
|
||||
dishes: { idColumn: "id", translationTable: "dish_translations" },
|
||||
sections: { idColumn: "key", translationTable: "section_translations" },
|
||||
allergens: { idColumn: "code", translationTable: "allergen_translations" },
|
||||
additives: { idColumn: "code", translationTable: "additive_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")
|
||||
? "https://api-free.deepl.com"
|
||||
: "https://api.deepl.com";
|
||||
@@ -71,25 +81,31 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ configured: false });
|
||||
}
|
||||
|
||||
try {
|
||||
const [en, pl] = await Promise.all([
|
||||
translateWithDeepL(text, "EN", apiKey),
|
||||
translateWithDeepL(text, "PL", apiKey),
|
||||
]);
|
||||
if (!en || !pl) throw new Error("Keine Übersetzung erhalten.");
|
||||
const activeCodes = (await listActiveLanguageCodes()).filter((c) => c !== "de");
|
||||
const targets = activeCodes
|
||||
.map((code) => ({ code, target: DEEPL_TARGET_LANG[code.toLowerCase()] }))
|
||||
.filter((t): t is { code: string; target: string } => Boolean(t.target));
|
||||
|
||||
if (persist) {
|
||||
// table/idColumn/textColumn kommen ausschließlich aus dem oben
|
||||
// geprüften ALLOWED_TABLES-Whitelist, nie aus Nutzereingaben –
|
||||
// sicher für die String-Interpolation in der Query.
|
||||
await query(
|
||||
`update ${table} set ${config.textColumn}_en = $1, ${config.textColumn}_pl = $2
|
||||
where ${config.idColumn} = $3`,
|
||||
[en, pl, id]
|
||||
);
|
||||
try {
|
||||
const results = await Promise.all(
|
||||
targets.map(async (t) => ({
|
||||
code: t.code,
|
||||
text: await translateWithDeepL(text, t.target, apiKey),
|
||||
}))
|
||||
);
|
||||
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) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : "Übersetzung fehlgeschlagen." },
|
||||
|
||||
+30
-4
@@ -1,6 +1,7 @@
|
||||
import Link from "next/link";
|
||||
import { query } from "@/lib/db";
|
||||
import { loadMenuEntries } from "@/lib/db/menu-entries";
|
||||
import { loadTranslations } from "@/lib/db/translations";
|
||||
import { getSettings } from "@/lib/db/settings";
|
||||
import {
|
||||
addDays,
|
||||
@@ -33,14 +34,39 @@ export default async function Home({
|
||||
iso: addDays(monday, i),
|
||||
}));
|
||||
|
||||
const [sections, entries, allergens, additives, settings] = await Promise.all([
|
||||
query<Section>("select * from sections order by sort"),
|
||||
const [
|
||||
sectionRows,
|
||||
entries,
|
||||
allergenRows,
|
||||
additiveRows,
|
||||
settings,
|
||||
sectionTranslations,
|
||||
allergenTranslations,
|
||||
additiveTranslations,
|
||||
] = await Promise.all([
|
||||
query<Omit<Section, "translations">>("select key, label, time_range, sort from sections order by sort"),
|
||||
loadMenuEntries(monday, friday),
|
||||
query<CodeItem>("select * from allergens order by sort"),
|
||||
query<CodeItem>("select * from additives order by sort"),
|
||||
query<Omit<CodeItem, "translations">>("select code, name, sort from allergens order by sort"),
|
||||
query<Omit<CodeItem, "translations">>("select code, name, sort from additives order by sort"),
|
||||
getSettings(),
|
||||
loadTranslations("section_translations"),
|
||||
loadTranslations("allergen_translations"),
|
||||
loadTranslations("additive_translations"),
|
||||
]);
|
||||
|
||||
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 labelingDetailed = (settings?.labeling_mode ?? "detailed") !== "notice";
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useCallback, useEffect, useState } from "react";
|
||||
import { formatMessage } from "@/lib/i18n/shared";
|
||||
import { localized } 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({
|
||||
table,
|
||||
@@ -18,6 +18,7 @@ export default function CodeListManager({
|
||||
}) {
|
||||
const dict = useDict();
|
||||
const locale = useLocale();
|
||||
const languages = useLanguages().filter((l) => l.code !== "de");
|
||||
const title =
|
||||
table === "allergens" ? dict.codeList.allergensTitle : dict.codeList.additivesTitle;
|
||||
const codeLabel =
|
||||
@@ -30,8 +31,7 @@ export default function CodeListManager({
|
||||
original: string | null; // code vor der Bearbeitung, null = neu
|
||||
code: string;
|
||||
name: string;
|
||||
nameEn: string;
|
||||
namePl: string;
|
||||
translations: Record<string, string>;
|
||||
} | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
@@ -60,8 +60,7 @@ export default function CodeListManager({
|
||||
const payload = {
|
||||
code,
|
||||
name,
|
||||
name_en: editing.nameEn.trim() || null,
|
||||
name_pl: editing.namePl.trim() || null,
|
||||
translations: editing.translations,
|
||||
};
|
||||
|
||||
const res = editing.original
|
||||
@@ -118,7 +117,9 @@ export default function CodeListManager({
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data?.error ?? `Fehler (${res.status})`);
|
||||
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");
|
||||
} catch (err) {
|
||||
@@ -202,7 +203,7 @@ export default function CodeListManager({
|
||||
type="button"
|
||||
className="btn btn-add"
|
||||
onClick={() => {
|
||||
setEditing({ original: null, code: "", name: "", nameEn: "", namePl: "" });
|
||||
setEditing({ original: null, code: "", name: "", translations: {} });
|
||||
setTranslateState("idle");
|
||||
}}
|
||||
>
|
||||
@@ -237,25 +238,26 @@ export default function CodeListManager({
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="label">{dict.dishManager.nameEnLabel}</label>
|
||||
<input
|
||||
className="input"
|
||||
value={editing.nameEn}
|
||||
onChange={(e) => setEditing({ ...editing, nameEn: e.target.value })}
|
||||
/>
|
||||
{languages.length > 0 && (
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{languages.map((lang) => (
|
||||
<div key={lang.code}>
|
||||
<label className="label">{lang.label}</label>
|
||||
<input
|
||||
className="input"
|
||||
value={editing.translations[lang.code] ?? ""}
|
||||
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>
|
||||
{translateEnabled && (
|
||||
)}
|
||||
{translateEnabled && languages.length > 0 && (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -304,8 +306,7 @@ export default function CodeListManager({
|
||||
original: item.code,
|
||||
code: item.code,
|
||||
name: item.name,
|
||||
nameEn: item.name_en ?? "",
|
||||
namePl: item.name_pl ?? "",
|
||||
translations: { ...item.translations },
|
||||
});
|
||||
setTranslateState("idle");
|
||||
}}
|
||||
|
||||
+31
-34
@@ -6,13 +6,12 @@ import { formatMessage } from "@/lib/i18n/shared";
|
||||
import { localized } from "@/lib/types";
|
||||
import type { CodeItem, Dish } from "@/lib/types";
|
||||
import ChipPicker from "@/components/ChipPicker";
|
||||
import { useDict, useLocale } from "@/components/i18n/LocaleProvider";
|
||||
import { useDict, useLanguages, useLocale } from "@/components/i18n/LocaleProvider";
|
||||
|
||||
interface DishForm {
|
||||
id: string | null; // null = neues Gericht
|
||||
name: string;
|
||||
nameEn: string;
|
||||
namePl: string;
|
||||
translations: Record<string, string>;
|
||||
price: string;
|
||||
allergens: string[];
|
||||
additives: string[];
|
||||
@@ -21,8 +20,7 @@ interface DishForm {
|
||||
const EMPTY_FORM: DishForm = {
|
||||
id: null,
|
||||
name: "",
|
||||
nameEn: "",
|
||||
namePl: "",
|
||||
translations: {},
|
||||
price: "",
|
||||
allergens: [],
|
||||
additives: [],
|
||||
@@ -35,6 +33,7 @@ export default function DishManager({
|
||||
}) {
|
||||
const dict = useDict();
|
||||
const locale = useLocale();
|
||||
const languages = useLanguages().filter((l) => l.code !== "de");
|
||||
const [dishes, setDishes] = useState<Dish[]>([]);
|
||||
const [allergens, setAllergens] = useState<CodeItem[]>([]);
|
||||
const [additives, setAdditives] = useState<CodeItem[]>([]);
|
||||
@@ -93,11 +92,10 @@ export default function DishManager({
|
||||
}
|
||||
const payload = {
|
||||
name: form.name.trim(),
|
||||
name_en: form.nameEn.trim() || null,
|
||||
name_pl: form.namePl.trim() || null,
|
||||
price,
|
||||
allergens: form.allergens,
|
||||
additives: form.additives,
|
||||
translations: form.translations,
|
||||
};
|
||||
const res = form.id
|
||||
? await fetch(`/api/dishes/${form.id}`, {
|
||||
@@ -218,7 +216,9 @@ export default function DishManager({
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data?.error ?? `Fehler (${res.status})`);
|
||||
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");
|
||||
} catch (err) {
|
||||
@@ -352,31 +352,29 @@ export default function DishManager({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="label" htmlFor="dish-name-en">
|
||||
{dict.dishManager.nameEnLabel}
|
||||
</label>
|
||||
<input
|
||||
id="dish-name-en"
|
||||
className="input"
|
||||
value={form.nameEn}
|
||||
onChange={(e) => setForm({ ...form, nameEn: e.target.value })}
|
||||
/>
|
||||
{languages.length > 0 && (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{languages.map((lang) => (
|
||||
<div key={lang.code}>
|
||||
<label className="label" htmlFor={`dish-name-${lang.code}`}>
|
||||
{lang.label}
|
||||
</label>
|
||||
<input
|
||||
id={`dish-name-${lang.code}`}
|
||||
className="input"
|
||||
value={form.translations[lang.code] ?? ""}
|
||||
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>
|
||||
{translateEnabled && (
|
||||
)}
|
||||
{translateEnabled && languages.length > 0 && (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -495,8 +493,7 @@ export default function DishManager({
|
||||
setForm({
|
||||
id: dish.id,
|
||||
name: dish.name,
|
||||
nameEn: dish.name_en ?? "",
|
||||
namePl: dish.name_pl ?? "",
|
||||
translations: { ...dish.translations },
|
||||
price: String(dish.price).replace(".", ","),
|
||||
allergens: dish.allergens ?? [],
|
||||
additives: dish.additives ?? [],
|
||||
|
||||
@@ -7,13 +7,12 @@ import { useCallback, useEffect, useState } from "react";
|
||||
import { formatMessage } from "@/lib/i18n/shared";
|
||||
import { localized } 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 {
|
||||
key: string | null; // null = neue Essenszeit
|
||||
label: string;
|
||||
labelEn: string;
|
||||
labelPl: string;
|
||||
translations: Record<string, string>;
|
||||
timeFrom: string;
|
||||
timeTo: string;
|
||||
}
|
||||
@@ -21,8 +20,7 @@ interface SectionForm {
|
||||
const EMPTY_FORM: SectionForm = {
|
||||
key: null,
|
||||
label: "",
|
||||
labelEn: "",
|
||||
labelPl: "",
|
||||
translations: {},
|
||||
timeFrom: "",
|
||||
timeTo: "",
|
||||
};
|
||||
@@ -35,6 +33,7 @@ function splitTimeRange(range: string): { from: string; to: string } {
|
||||
export default function SectionManager() {
|
||||
const dict = useDict();
|
||||
const locale = useLocale();
|
||||
const languages = useLanguages().filter((l) => l.code !== "de");
|
||||
const [sections, setSections] = useState<Section[]>([]);
|
||||
const [form, setForm] = useState<SectionForm | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -61,9 +60,8 @@ export default function SectionManager() {
|
||||
if (!label || !form.timeFrom || !form.timeTo) return;
|
||||
const payload = {
|
||||
label,
|
||||
label_en: form.labelEn.trim() || null,
|
||||
label_pl: form.labelPl.trim() || null,
|
||||
time_range: `${form.timeFrom} - ${form.timeTo}`,
|
||||
translations: form.translations,
|
||||
};
|
||||
|
||||
const res = form.key
|
||||
@@ -160,30 +158,28 @@ export default function SectionManager() {
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="label" htmlFor="section-label-en">
|
||||
{dict.sectionManager.nameEnLabel}
|
||||
</label>
|
||||
<input
|
||||
id="section-label-en"
|
||||
className="input"
|
||||
value={form.labelEn}
|
||||
onChange={(e) => setForm({ ...form, labelEn: e.target.value })}
|
||||
/>
|
||||
{languages.length > 0 && (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{languages.map((lang) => (
|
||||
<div key={lang.code}>
|
||||
<label className="label" htmlFor={`section-label-${lang.code}`}>
|
||||
{lang.label}
|
||||
</label>
|
||||
<input
|
||||
id={`section-label-${lang.code}`}
|
||||
className="input"
|
||||
value={form.translations[lang.code] ?? ""}
|
||||
onChange={(e) =>
|
||||
setForm({
|
||||
...form,
|
||||
translations: { ...form.translations, [lang.code]: e.target.value },
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div>
|
||||
<label className="label" htmlFor="section-label-pl">
|
||||
{dict.sectionManager.namePlLabel}
|
||||
</label>
|
||||
<input
|
||||
id="section-label-pl"
|
||||
className="input"
|
||||
value={form.labelPl}
|
||||
onChange={(e) => setForm({ ...form, labelPl: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-3">
|
||||
<div className="max-w-32">
|
||||
<label className="label" htmlFor="section-time-from">
|
||||
@@ -267,8 +263,7 @@ export default function SectionManager() {
|
||||
setForm({
|
||||
key: section.key,
|
||||
label: section.label,
|
||||
labelEn: section.label_en ?? "",
|
||||
labelPl: section.label_pl ?? "",
|
||||
translations: { ...section.translations },
|
||||
timeFrom: from,
|
||||
timeTo: to,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
+71
-32
@@ -16,42 +16,81 @@ delete from allergens;
|
||||
delete from additives;
|
||||
|
||||
-- ---------- Essenszeiten ----------
|
||||
insert into sections (key, label, label_en, label_pl, time_range, sort) values
|
||||
('brotzeit', 'Brotzeit', 'Breakfast', 'Śniadanie', '09:00 - 10:00', 1),
|
||||
('mittagessen', 'Mittagessen', 'Lunch', 'Obiad', '11:30 - 13:30', 2),
|
||||
('mittagessen_veg', 'Mittagessen vegetarisch', 'Lunch (vegetarian)', 'Obiad (wegetariański)', '11:30 - 13:30', 3),
|
||||
('suppe', 'Suppe', 'Soup', 'Zupa', '11:30 - 13:30', 4);
|
||||
insert into sections (key, label, time_range, sort) values
|
||||
('brotzeit', 'Brotzeit', '09:00 - 10:00', 1),
|
||||
('mittagessen', 'Mittagessen', '11:30 - 13:30', 2),
|
||||
('mittagessen_veg', 'Mittagessen vegetarisch', '11:30 - 13:30', 3),
|
||||
('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) ----------
|
||||
insert into allergens (code, name, name_en, name_pl, sort) values
|
||||
('A', 'Glutenhaltiges Getreide', 'Cereals containing gluten', 'Zboża zawierające gluten', 1),
|
||||
('B', 'Krebstiere', 'Crustaceans', 'Skorupiaki', 2),
|
||||
('C', 'Eier', 'Eggs', 'Jaja', 3),
|
||||
('D', 'Fisch', 'Fish', 'Ryby', 4),
|
||||
('E', 'Erdnüsse', 'Peanuts', 'Orzeszki ziemne', 5),
|
||||
('F', 'Sojabohnen', 'Soybeans', 'Soja', 6),
|
||||
('G', 'Milch (einschließlich Laktose)', 'Milk (including lactose)', 'Mleko (w tym laktoza)', 7),
|
||||
('H', 'Schalenfrüchte (Nüsse)', 'Tree nuts', 'Orzechy', 8),
|
||||
('I', 'Sellerie', 'Celery', 'Seler', 9),
|
||||
('J', 'Senf', 'Mustard', 'Gorczyca', 10),
|
||||
('K', 'Sesamsamen', 'Sesame seeds', 'Nasiona sezamu', 11),
|
||||
('L', 'Schwefeldioxid und Sulfite', 'Sulphur dioxide and sulphites', 'Dwutlenek siarki i siarczyny', 12),
|
||||
('M', 'Lupinen', 'Lupin', 'Łubin', 13),
|
||||
('N', 'Weichtiere', 'Molluscs', 'Mięczaki', 14);
|
||||
insert into allergens (code, name, sort) values
|
||||
('A', 'Glutenhaltiges Getreide', 1),
|
||||
('B', 'Krebstiere', 2),
|
||||
('C', 'Eier', 3),
|
||||
('D', 'Fisch', 4),
|
||||
('E', 'Erdnüsse', 5),
|
||||
('F', 'Sojabohnen', 6),
|
||||
('G', 'Milch (einschließlich Laktose)', 7),
|
||||
('H', 'Schalenfrüchte (Nüsse)', 8),
|
||||
('I', 'Sellerie', 9),
|
||||
('J', 'Senf', 10),
|
||||
('K', 'Sesamsamen', 11),
|
||||
('L', 'Schwefeldioxid und Sulfite', 12),
|
||||
('M', 'Lupinen', 13),
|
||||
('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) ----------
|
||||
insert into additives (code, name, name_en, name_pl, sort) values
|
||||
('1', 'mit Farbstoff', 'with colouring', 'z barwnikiem', 1),
|
||||
('2', 'mit Konservierungsstoff', 'with preservative', 'z konserwantem', 2),
|
||||
('3', 'mit Antioxidationsmittel', 'with antioxidant', 'z przeciwutleniaczem', 3),
|
||||
('4', 'mit Geschmacksverstärker', 'with flavour enhancer', 'ze wzmacniaczem smaku', 4),
|
||||
('5', 'geschwefelt', 'sulphured', 'siarkowany', 5),
|
||||
('6', 'geschwärzt', 'blackened', 'barwiony na czarno', 6),
|
||||
('7', 'gewachst', 'waxed', 'woskowany', 7),
|
||||
('8', 'mit Phosphat', 'with phosphate', 'z fosforanem', 8),
|
||||
('9', 'mit Süßungsmittel(n)', 'with sweetener(s)', 'ze słodzikiem/słodzikami', 9),
|
||||
('10', 'enthält eine Phenylalaninquelle', 'contains a source of phenylalanine', 'zawiera źródło fenyloalaniny', 10),
|
||||
('11', 'koffeinhaltig', 'caffeinated', 'zawiera kofeinę', 11);
|
||||
insert into additives (code, name, sort) values
|
||||
('1', 'mit Farbstoff', 1),
|
||||
('2', 'mit Konservierungsstoff', 2),
|
||||
('3', 'mit Antioxidationsmittel', 3),
|
||||
('4', 'mit Geschmacksverstärker', 4),
|
||||
('5', 'geschwefelt', 5),
|
||||
('6', 'geschwärzt', 6),
|
||||
('7', 'gewachst', 7),
|
||||
('8', 'mit Phosphat', 8),
|
||||
('9', 'mit Süßungsmittel(n)', 9),
|
||||
('10', 'enthält eine Phenylalaninquelle', 10),
|
||||
('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 ----------
|
||||
insert into dishes (name, price) values
|
||||
|
||||
+68
-39
@@ -1,39 +1,70 @@
|
||||
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";
|
||||
|
||||
// "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
|
||||
// String-Interpolation in der Query.
|
||||
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[]> {
|
||||
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(
|
||||
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> {
|
||||
const maxRow = await queryOne<{ sort: number }>(
|
||||
`select sort from ${table} order by sort desc limit 1`
|
||||
);
|
||||
return (await queryOne<CodeItem>(
|
||||
`insert into ${table} (code, name, name_en, name_pl, sort) values ($1, $2, $3, $4, $5) returning *`,
|
||||
[input.code, input.name, input.name_en, input.name_pl, (maxRow?.sort ?? 0) + 1]
|
||||
const row = (await queryOne<CodeRow>(
|
||||
`insert into ${table} (code, name, sort) values ($1, $2, $3) returning code, name, sort`,
|
||||
[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(
|
||||
table: CodeTable,
|
||||
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> {
|
||||
return queryOne<CodeItem>(
|
||||
`update ${table} set code = $1, name = $2, name_en = $3, name_pl = $4 where code = $5 returning *`,
|
||||
[input.code, input.name, input.name_en, input.name_pl, originalCode]
|
||||
const row = await queryOne<CodeRow>(
|
||||
`update ${table} set code = $1, name = $2 where code = $3 returning code, name, sort`,
|
||||
[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> {
|
||||
@@ -43,8 +74,7 @@ export async function deleteCode(table: CodeTable, code: string): Promise<void>
|
||||
export interface CodeImportRow {
|
||||
code: string;
|
||||
name: string;
|
||||
name_en: string | null;
|
||||
name_pl: string | null;
|
||||
translations: Record<string, string>;
|
||||
}
|
||||
|
||||
/** CSV-Import: legt neue Kürzel an, bestehende (gleicher Code) werden
|
||||
@@ -54,32 +84,31 @@ export async function importCodes(
|
||||
table: CodeTable,
|
||||
rows: CodeImportRow[]
|
||||
): Promise<{ created: number; updated: number }> {
|
||||
return withTransaction(async (client) => {
|
||||
const existing = await client.query<{ code: string }>(`select code from ${table}`);
|
||||
const known = new Set(existing.rows.map((r) => r.code));
|
||||
const maxRow = await client.query<{ sort: number }>(
|
||||
`select coalesce(max(sort), 0) as sort from ${table}`
|
||||
);
|
||||
let nextSort = (maxRow.rows[0]?.sort ?? 0) + 1;
|
||||
let created = 0;
|
||||
let updated = 0;
|
||||
const existing = await query<{ code: string }>(`select code from ${table}`);
|
||||
const known = new Set(existing.map((r) => r.code));
|
||||
const maxRow = await queryOne<{ sort: number }>(
|
||||
`select coalesce(max(sort), 0) as sort from ${table}`
|
||||
);
|
||||
let nextSort = (maxRow?.sort ?? 0) + 1;
|
||||
let created = 0;
|
||||
let updated = 0;
|
||||
|
||||
for (const row of rows) {
|
||||
if (known.has(row.code)) {
|
||||
await client.query(
|
||||
`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++;
|
||||
} else {
|
||||
await client.query(
|
||||
`insert into ${table} (code, name, name_en, name_pl, sort) values ($1, $2, $3, $4, $5)`,
|
||||
[row.code, row.name, row.name_en, row.name_pl, nextSort++]
|
||||
);
|
||||
known.add(row.code);
|
||||
created++;
|
||||
}
|
||||
for (const row of rows) {
|
||||
if (known.has(row.code)) {
|
||||
await query(`update ${table} set name = $1 where code = $2`, [row.name, row.code]);
|
||||
updated++;
|
||||
} else {
|
||||
await query(`insert into ${table} (code, name, sort) values ($1, $2, $3)`, [
|
||||
row.code,
|
||||
row.name,
|
||||
nextSort++,
|
||||
]);
|
||||
known.add(row.code);
|
||||
created++;
|
||||
}
|
||||
return { created, updated };
|
||||
});
|
||||
if (Object.keys(row.translations).length > 0) {
|
||||
await replaceTranslations(TRANSLATION_TABLE[table], row.code, row.translations);
|
||||
}
|
||||
}
|
||||
return { created, updated };
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import "server-only";
|
||||
import { query } from "@/lib/db";
|
||||
import { loadTranslationsFor } from "./translations";
|
||||
import type { MenuEntry } from "@/lib/types";
|
||||
|
||||
interface MenuEntryRow {
|
||||
@@ -9,8 +10,6 @@ interface MenuEntryRow {
|
||||
position: number;
|
||||
dish_id: string;
|
||||
name: string;
|
||||
name_en: string | null;
|
||||
name_pl: string | null;
|
||||
price: string;
|
||||
allergens: string[];
|
||||
additives: string[];
|
||||
@@ -22,7 +21,7 @@ interface MenuEntryRow {
|
||||
export async function loadMenuEntries(fromISO: string, toISO: string): Promise<MenuEntry[]> {
|
||||
const rows = await query<MenuEntryRow>(
|
||||
`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
|
||||
join dishes d on d.id = me.dish_id
|
||||
where me.menu_date between $1 and $2
|
||||
@@ -30,6 +29,9 @@ export async function loadMenuEntries(fromISO: string, toISO: string): Promise<M
|
||||
[fromISO, toISO]
|
||||
);
|
||||
|
||||
const dishIds = [...new Set(rows.map((r) => r.dish_id))];
|
||||
const translations = await loadTranslationsFor("dish_translations", dishIds);
|
||||
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
menu_date: r.menu_date,
|
||||
@@ -38,8 +40,7 @@ export async function loadMenuEntries(fromISO: string, toISO: string): Promise<M
|
||||
dish: {
|
||||
id: r.dish_id,
|
||||
name: r.name,
|
||||
name_en: r.name_en,
|
||||
name_pl: r.name_pl,
|
||||
translations: translations.get(r.dish_id) ?? {},
|
||||
price: Number(r.price),
|
||||
allergens: r.allergens,
|
||||
additives: r.additives,
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
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";
|
||||
|
||||
const ID_COLUMN: Record<TranslationTable, string> = {
|
||||
dish_translations: "dish_id",
|
||||
section_translations: "section_key",
|
||||
allergen_translations: "allergen_code",
|
||||
additive_translations: "additive_code",
|
||||
};
|
||||
|
||||
/** 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]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -93,8 +93,6 @@ const de = {
|
||||
newDish: "Neues Gericht",
|
||||
editDish: "Gericht bearbeiten",
|
||||
nameLabel: "Bezeichnung",
|
||||
nameEnLabel: "Name (Englisch)",
|
||||
namePlLabel: "Name (Polnisch)",
|
||||
autoTranslate: "Automatisch übersetzen",
|
||||
translating: "Übersetze…",
|
||||
translated: "Übersetzt.",
|
||||
@@ -150,8 +148,6 @@ const de = {
|
||||
newSection: "Neue Essenszeit",
|
||||
editSection: "Essenszeit bearbeiten",
|
||||
nameLabel: "Bezeichnung",
|
||||
nameEnLabel: "Bezeichnung (Englisch)",
|
||||
namePlLabel: "Bezeichnung (Polnisch)",
|
||||
timeFrom: "Von",
|
||||
timeTo: "Bis",
|
||||
moveUp: "Nach oben",
|
||||
|
||||
+21
-7
@@ -12,8 +12,9 @@ export interface Profile {
|
||||
export interface Section {
|
||||
key: string;
|
||||
label: string;
|
||||
label_en: string | null;
|
||||
label_pl: string | null;
|
||||
// Sprachcode (z. B. "en", "fr") -> übersetzter Name. Enthält nie "de" -
|
||||
// Deutsch ist immer die "label"-Spalte selbst.
|
||||
translations: Record<string, string>;
|
||||
time_range: string;
|
||||
sort: number;
|
||||
}
|
||||
@@ -21,8 +22,7 @@ export interface Section {
|
||||
export interface Dish {
|
||||
id: string;
|
||||
name: string;
|
||||
name_en: string | null;
|
||||
name_pl: string | null;
|
||||
translations: Record<string, string>;
|
||||
price: number;
|
||||
allergens: string[];
|
||||
additives: string[];
|
||||
@@ -39,8 +39,7 @@ export interface MenuEntry {
|
||||
export interface CodeItem {
|
||||
code: string;
|
||||
name: string;
|
||||
name_en: string | null;
|
||||
name_pl: string | null;
|
||||
translations: Record<string, string>;
|
||||
sort: number;
|
||||
}
|
||||
|
||||
@@ -53,10 +52,25 @@ export interface DishSuggestion {
|
||||
|
||||
/** Übersetzten Namen für die aktuelle Sprache liefern, sonst Deutsch als Fallback. */
|
||||
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;
|
||||
// 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
|
||||
): string {
|
||||
const de = item.name ?? item.label ?? "";
|
||||
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 === "pl") return item.name_pl || item.label_pl || de;
|
||||
return de;
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "canteen-menu",
|
||||
"version": "1.10.0",
|
||||
"version": "1.11.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"predev": "node --env-file-if-exists=.env.local db/migrate.mjs && node --env-file-if-exists=.env.local scripts/bootstrap-admin.mjs",
|
||||
|
||||
Reference in New Issue
Block a user