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]
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user