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/),
|
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.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 };
|
||||||
|
|||||||
@@ -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;
|
||||||
values.push(key);
|
if (fields.length > 0) {
|
||||||
|
values.push(key);
|
||||||
const section = await queryOne<Section>(
|
row = await queryOne<SectionRow>(
|
||||||
`update sections set ${fields.join(", ")} where key = $${values.length} returning *`,
|
`update sections set ${fields.join(", ")} where key = $${values.length}
|
||||||
values
|
returning key, label, time_range, sort`,
|
||||||
);
|
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
-27
@@ -1,21 +1,31 @@
|
|||||||
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" },
|
||||||
};
|
};
|
||||||
|
|
||||||
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 +81,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." },
|
||||||
|
|||||||
+30
-4
@@ -1,6 +1,7 @@
|
|||||||
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 { getSettings } from "@/lib/db/settings";
|
import { getSettings } from "@/lib/db/settings";
|
||||||
import {
|
import {
|
||||||
addDays,
|
addDays,
|
||||||
@@ -33,14 +34,39 @@ 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,
|
||||||
|
] = 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"),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
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";
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
<div className="grid gap-2 sm:grid-cols-2">
|
{languages.length > 0 && (
|
||||||
<div>
|
<div className="grid gap-2 sm:grid-cols-2">
|
||||||
<label className="label">{dict.dishManager.nameEnLabel}</label>
|
{languages.map((lang) => (
|
||||||
<input
|
<div key={lang.code}>
|
||||||
className="input"
|
<label className="label">{lang.label}</label>
|
||||||
value={editing.nameEn}
|
<input
|
||||||
onChange={(e) => setEditing({ ...editing, nameEn: e.target.value })}
|
className="input"
|
||||||
/>
|
value={editing.translations[lang.code] ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditing({
|
||||||
|
...editing,
|
||||||
|
translations: { ...editing.translations, [lang.code]: e.target.value },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
)}
|
||||||
<label className="label">{dict.dishManager.namePlLabel}</label>
|
{translateEnabled && languages.length > 0 && (
|
||||||
<input
|
|
||||||
className="input"
|
|
||||||
value={editing.namePl}
|
|
||||||
onChange={(e) => setEditing({ ...editing, namePl: e.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{translateEnabled && (
|
|
||||||
<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");
|
||||||
}}
|
}}
|
||||||
|
|||||||
+31
-34
@@ -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>
|
||||||
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
{languages.length > 0 && (
|
||||||
<div>
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
<label className="label" htmlFor="dish-name-en">
|
{languages.map((lang) => (
|
||||||
{dict.dishManager.nameEnLabel}
|
<div key={lang.code}>
|
||||||
</label>
|
<label className="label" htmlFor={`dish-name-${lang.code}`}>
|
||||||
<input
|
{lang.label}
|
||||||
id="dish-name-en"
|
</label>
|
||||||
className="input"
|
<input
|
||||||
value={form.nameEn}
|
id={`dish-name-${lang.code}`}
|
||||||
onChange={(e) => setForm({ ...form, nameEn: e.target.value })}
|
className="input"
|
||||||
/>
|
value={form.translations[lang.code] ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm({
|
||||||
|
...form,
|
||||||
|
translations: { ...form.translations, [lang.code]: e.target.value },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
)}
|
||||||
<label className="label" htmlFor="dish-name-pl">
|
{translateEnabled && languages.length > 0 && (
|
||||||
{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 && (
|
|
||||||
<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 ?? [],
|
||||||
|
|||||||
@@ -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>
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
{languages.length > 0 && (
|
||||||
<div>
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
<label className="label" htmlFor="section-label-en">
|
{languages.map((lang) => (
|
||||||
{dict.sectionManager.nameEnLabel}
|
<div key={lang.code}>
|
||||||
</label>
|
<label className="label" htmlFor={`section-label-${lang.code}`}>
|
||||||
<input
|
{lang.label}
|
||||||
id="section-label-en"
|
</label>
|
||||||
className="input"
|
<input
|
||||||
value={form.labelEn}
|
id={`section-label-${lang.code}`}
|
||||||
onChange={(e) => setForm({ ...form, labelEn: e.target.value })}
|
className="input"
|
||||||
/>
|
value={form.translations[lang.code] ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm({
|
||||||
|
...form,
|
||||||
|
translations: { ...form.translations, [lang.code]: e.target.value },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</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="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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
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
|
||||||
|
|||||||
+68
-39
@@ -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?.sort ?? 0) + 1;
|
||||||
let nextSort = (maxRow.rows[0]?.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`,
|
updated++;
|
||||||
[row.name, row.name_en, row.name_pl, row.code]
|
} else {
|
||||||
);
|
await query(`insert into ${table} (code, name, sort) values ($1, $2, $3)`, [
|
||||||
updated++;
|
row.code,
|
||||||
} else {
|
row.name,
|
||||||
await client.query(
|
nextSort++,
|
||||||
`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++;
|
||||||
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 "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,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",
|
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 +148,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",
|
||||||
|
|||||||
+21
-7
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,10 +52,25 @@ export interface DishSuggestion {
|
|||||||
|
|
||||||
/** Ü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;
|
||||||
|
// 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 ?? "";
|
||||||
|
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.11.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