feat: Speiseplan-Inhalte variabel mehrsprachig (Teil 2)
Gerichtnamen, Essenszeiten und Allergen-/Zusatzstoff-Bezeichnungen sind jetzt genauso dynamisch mehrsprachig wie die Bedienoberfläche aus v1.10.0, statt fest auf Englisch/Polnisch begrenzt zu sein. Neue Übersetzungstabellen ersetzen die alten name_en/name_pl-Spalten, DeepL-Auto-Übersetzung und CSV-Import/-Export unterstützen jetzt beliebig viele aktive Sprachen.
This commit is contained in:
+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;
|
||||
|
||||
Reference in New Issue
Block a user