From 474467811cca6cba4d2e8bc486528dd68aa70d32 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Sun, 19 Jul 2026 23:10:36 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20CSV-Import/-Export=20f=C3=BCr=20Gericht?= =?UTF-8?q?e/Allergene/Zusatzstoffe,=20RSS-Fix=20f=C3=BCr=20mehrere=20Esse?= =?UTF-8?q?nszeiten?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CSV-Import/-Export (Gerichte, Allergene, Zusatzstoffe): neue Export-/ Import-Routen unter app/api/*/export und */import, hand-geschriebener CSV-Parser/-Serializer (lib/csv.ts, keine neue Abhängigkeit), Upsert über Kürzel (Allergene/Zusatzstoffe) bzw. exakten Namen (Gerichte, mit Ambiguitäts-Check bei Namensdopplung). UI-Buttons in DishManager und CodeListManager. RSS-Feed: zusätzlich zu ausgeben, da mehrere Feed-Reader description als kürzbare Vorschau behandeln und dadurch bei mehreren Essenszeiten pro Tag nur die erste zeigten. Version 1.7.0. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 29 ++++++++ app/api/additives/export/route.ts | 24 +++++++ app/api/additives/import/route.ts | 53 ++++++++++++++ app/api/allergens/export/route.ts | 24 +++++++ app/api/allergens/import/route.ts | 53 ++++++++++++++ app/api/dishes/export/route.ts | 32 +++++++++ app/api/dishes/import/route.ts | 110 ++++++++++++++++++++++++++++++ app/feed.xml/route.ts | 10 ++- components/CodeListManager.tsx | 68 ++++++++++++++++++ components/DishManager.tsx | 68 ++++++++++++++++++ lib/csv.ts | 98 ++++++++++++++++++++++++++ lib/db/code-lists.ts | 46 ++++++++++++- lib/i18n/dictionaries.ts | 42 ++++++++++++ package-lock.json | 4 +- package.json | 2 +- 15 files changed, 658 insertions(+), 5 deletions(-) create mode 100644 app/api/additives/export/route.ts create mode 100644 app/api/additives/import/route.ts create mode 100644 app/api/allergens/export/route.ts create mode 100644 app/api/allergens/import/route.ts create mode 100644 app/api/dishes/export/route.ts create mode 100644 app/api/dishes/import/route.ts create mode 100644 lib/csv.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fe353b..b3bf387 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,35 @@ Alle nennenswerten Änderungen an diesem Projekt werden hier dokumentiert. Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), Versionierung nach [Semantic Versioning](https://semver.org/lang/de/). +## [1.7.0] – 2026-07-19 + +### Hinzugefügt +- CSV-Import/-Export für Gerichte, Allergene und Zusatzstoffe (Buttons + „Exportieren“/„Importieren“ auf den jeweiligen Verwaltungsseiten). + Export lädt die aktuelle Liste als CSV herunter (Gerichte: + `name,name_en,name_pl,price,allergens,additives`, Allergene/ + Zusatzstoffe: `code,name,name_en,name_pl`; Kürzel bei Gerichten + Semikolon-getrennt in einer Zelle, z. B. `A;G`). Import liest dieselbe + Spaltenstruktur (Reihenfolge egal, Groß-/Kleinschreibung der + Kopfzeile wird ignoriert) und aktualisiert bestehende Einträge + (Abgleich über Kürzel bei Allergenen/Zusatzstoffen, über den exakten + Namen bei Gerichten) bzw. legt neue an. Zeilen mit fehlenden + Pflichtfeldern, ungültigem Preis oder mehrdeutigem Gerichtsnamen + (mehrere Gerichte mit demselben Namen) werden übersprungen und mit + Grund in der Erfolgsmeldung aufgelistet, statt den ganzen Import + abzubrechen. + +### Behoben +- RSS-Feed (`/feed.xml`): Mehrere Feed-Reader (u. a. Outlook, + Thunderbird) behandeln `` als kurze Vorschau und kürzen + sie eigenmächtig – dadurch war bei Tagen mit mehreren Essenszeiten + oft nur die erste sichtbar, obwohl der Server bereits alle + Essenszeiten des Tages in der Beschreibung lieferte. Zusätzlich zu + `` wird jetzt auch `` (RSS-Content- + Modul) mit demselben vollständigen Inhalt ausgeliefert – von den + meisten Readern als vollständiger Artikeltext erkannt und ungekürzt + angezeigt. + ## [1.6.3] – 2026-07-18 ### Behoben diff --git a/app/api/additives/export/route.ts b/app/api/additives/export/route.ts new file mode 100644 index 0000000..bf65f13 --- /dev/null +++ b/app/api/additives/export/route.ts @@ -0,0 +1,24 @@ +import { listCodes } from "@/lib/db/code-lists"; +import { authErrorResponse, requireRole } from "@/lib/auth/session"; +import { toCsv } from "@/lib/csv"; + +export async function GET() { + try { + await requireRole(["staff", "admin"]); + const items = await listCodes("additives"); + const rows = [ + ["code", "name", "name_en", "name_pl"], + ...items.map((i) => [i.code, i.name, i.name_en ?? "", i.name_pl ?? ""]), + ]; + return new Response(toCsv(rows), { + headers: { + "Content-Type": "text/csv; charset=utf-8", + "Content-Disposition": 'attachment; filename="zusatzstoffe.csv"', + }, + }); + } catch (err) { + const res = authErrorResponse(err); + if (res) return res; + throw err; + } +} diff --git a/app/api/additives/import/route.ts b/app/api/additives/import/route.ts new file mode 100644 index 0000000..8fa72b1 --- /dev/null +++ b/app/api/additives/import/route.ts @@ -0,0 +1,53 @@ +import { NextResponse } from "next/server"; +import { importCodes, type CodeImportRow } from "@/lib/db/code-lists"; +import { authErrorResponse, requireRole } from "@/lib/auth/session"; +import { isSameOrigin } from "@/lib/origin"; +import { parseCsvWithHeader } from "@/lib/csv"; + +export async function POST(request: Request) { + if (!isSameOrigin(request)) { + return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 }); + } + try { + await requireRole(["staff", "admin"]); + const form = await request.formData(); + const file = form.get("file"); + if (!(file instanceof File)) { + return NextResponse.json({ error: "invalid_input" }, { status: 400 }); + } + + const { header, rows: cells } = parseCsvWithHeader(await file.text()); + const codeIdx = header.indexOf("code"); + const nameIdx = header.indexOf("name"); + if (codeIdx === -1 || nameIdx === -1) { + return NextResponse.json({ error: "missing_columns" }, { status: 400 }); + } + const nameEnIdx = header.indexOf("name_en"); + const namePlIdx = header.indexOf("name_pl"); + + const rows: CodeImportRow[] = []; + const skipped: { row: number; reason: string }[] = []; + cells.forEach((c, i) => { + const rowNum = i + 2; // 1-indexiert + Kopfzeile + const code = (c[codeIdx] ?? "").trim(); + const name = (c[nameIdx] ?? "").trim(); + if (!code || !name) { + skipped.push({ row: rowNum, reason: "Kürzel oder Bezeichnung fehlt" }); + return; + } + rows.push({ + code, + name, + name_en: nameEnIdx >= 0 ? c[nameEnIdx]?.trim() || null : null, + name_pl: namePlIdx >= 0 ? c[namePlIdx]?.trim() || null : null, + }); + }); + + const result = rows.length > 0 ? await importCodes("additives", rows) : { created: 0, updated: 0 }; + return NextResponse.json({ ...result, skipped }); + } catch (err) { + const res = authErrorResponse(err); + if (res) return res; + throw err; + } +} diff --git a/app/api/allergens/export/route.ts b/app/api/allergens/export/route.ts new file mode 100644 index 0000000..bafa4c5 --- /dev/null +++ b/app/api/allergens/export/route.ts @@ -0,0 +1,24 @@ +import { listCodes } from "@/lib/db/code-lists"; +import { authErrorResponse, requireRole } from "@/lib/auth/session"; +import { toCsv } from "@/lib/csv"; + +export async function GET() { + try { + await requireRole(["staff", "admin"]); + const items = await listCodes("allergens"); + const rows = [ + ["code", "name", "name_en", "name_pl"], + ...items.map((i) => [i.code, i.name, i.name_en ?? "", i.name_pl ?? ""]), + ]; + return new Response(toCsv(rows), { + headers: { + "Content-Type": "text/csv; charset=utf-8", + "Content-Disposition": 'attachment; filename="allergene.csv"', + }, + }); + } catch (err) { + const res = authErrorResponse(err); + if (res) return res; + throw err; + } +} diff --git a/app/api/allergens/import/route.ts b/app/api/allergens/import/route.ts new file mode 100644 index 0000000..c49203b --- /dev/null +++ b/app/api/allergens/import/route.ts @@ -0,0 +1,53 @@ +import { NextResponse } from "next/server"; +import { importCodes, type CodeImportRow } from "@/lib/db/code-lists"; +import { authErrorResponse, requireRole } from "@/lib/auth/session"; +import { isSameOrigin } from "@/lib/origin"; +import { parseCsvWithHeader } from "@/lib/csv"; + +export async function POST(request: Request) { + if (!isSameOrigin(request)) { + return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 }); + } + try { + await requireRole(["staff", "admin"]); + const form = await request.formData(); + const file = form.get("file"); + if (!(file instanceof File)) { + return NextResponse.json({ error: "invalid_input" }, { status: 400 }); + } + + const { header, rows: cells } = parseCsvWithHeader(await file.text()); + const codeIdx = header.indexOf("code"); + const nameIdx = header.indexOf("name"); + if (codeIdx === -1 || nameIdx === -1) { + return NextResponse.json({ error: "missing_columns" }, { status: 400 }); + } + const nameEnIdx = header.indexOf("name_en"); + const namePlIdx = header.indexOf("name_pl"); + + const rows: CodeImportRow[] = []; + const skipped: { row: number; reason: string }[] = []; + cells.forEach((c, i) => { + const rowNum = i + 2; // 1-indexiert + Kopfzeile + const code = (c[codeIdx] ?? "").trim(); + const name = (c[nameIdx] ?? "").trim(); + if (!code || !name) { + skipped.push({ row: rowNum, reason: "Kürzel oder Bezeichnung fehlt" }); + return; + } + rows.push({ + code, + name, + name_en: nameEnIdx >= 0 ? c[nameEnIdx]?.trim() || null : null, + name_pl: namePlIdx >= 0 ? c[namePlIdx]?.trim() || null : null, + }); + }); + + const result = rows.length > 0 ? await importCodes("allergens", rows) : { created: 0, updated: 0 }; + return NextResponse.json({ ...result, skipped }); + } catch (err) { + const res = authErrorResponse(err); + if (res) return res; + throw err; + } +} diff --git a/app/api/dishes/export/route.ts b/app/api/dishes/export/route.ts new file mode 100644 index 0000000..bc284a0 --- /dev/null +++ b/app/api/dishes/export/route.ts @@ -0,0 +1,32 @@ +import { query } from "@/lib/db"; +import { authErrorResponse, requireRole } from "@/lib/auth/session"; +import { toCsv } from "@/lib/csv"; +import type { Dish } from "@/lib/types"; + +export async function GET() { + try { + await requireRole(["staff", "admin"]); + const dishes = await query("select * from dishes order by name"); + const rows = [ + ["name", "name_en", "name_pl", "price", "allergens", "additives"], + ...dishes.map((d) => [ + d.name, + d.name_en ?? "", + d.name_pl ?? "", + String(d.price), + (d.allergens ?? []).join(";"), + (d.additives ?? []).join(";"), + ]), + ]; + return new Response(toCsv(rows), { + headers: { + "Content-Type": "text/csv; charset=utf-8", + "Content-Disposition": 'attachment; filename="gerichte.csv"', + }, + }); + } catch (err) { + const res = authErrorResponse(err); + if (res) return res; + throw err; + } +} diff --git a/app/api/dishes/import/route.ts b/app/api/dishes/import/route.ts new file mode 100644 index 0000000..8861be3 --- /dev/null +++ b/app/api/dishes/import/route.ts @@ -0,0 +1,110 @@ +import { NextResponse } from "next/server"; +import { withTransaction } from "@/lib/db"; +import { authErrorResponse, requireRole } from "@/lib/auth/session"; +import { isSameOrigin } from "@/lib/origin"; +import { parseCsvWithHeader, splitCodes } from "@/lib/csv"; + +interface ImportRow { + rowNum: number; + name: string; + name_en: string | null; + name_pl: string | null; + price: number; + allergens: string[]; + additives: string[]; +} + +export async function POST(request: Request) { + if (!isSameOrigin(request)) { + return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 }); + } + try { + await requireRole(["staff", "admin"]); + const form = await request.formData(); + const file = form.get("file"); + if (!(file instanceof File)) { + return NextResponse.json({ error: "invalid_input" }, { status: 400 }); + } + + const { header, rows: cells } = parseCsvWithHeader(await file.text()); + const nameIdx = header.indexOf("name"); + const priceIdx = header.indexOf("price"); + if (nameIdx === -1 || priceIdx === -1) { + return NextResponse.json({ error: "missing_columns" }, { status: 400 }); + } + const nameEnIdx = header.indexOf("name_en"); + const namePlIdx = header.indexOf("name_pl"); + const allergensIdx = header.indexOf("allergens"); + const additivesIdx = header.indexOf("additives"); + + const rows: ImportRow[] = []; + const skipped: { row: number; reason: string }[] = []; + cells.forEach((c, i) => { + const rowNum = i + 2; // 1-indexiert + Kopfzeile + const name = (c[nameIdx] ?? "").trim(); + if (!name) { + skipped.push({ row: rowNum, reason: "Name fehlt" }); + return; + } + const priceRaw = (c[priceIdx] ?? "").trim().replace(",", "."); + const price = Number.parseFloat(priceRaw); + if (Number.isNaN(price) || price < 0) { + skipped.push({ row: rowNum, reason: `Ungültiger Preis „${c[priceIdx] ?? ""}“` }); + return; + } + rows.push({ + rowNum, + name, + name_en: nameEnIdx >= 0 ? c[nameEnIdx]?.trim() || null : null, + name_pl: namePlIdx >= 0 ? c[namePlIdx]?.trim() || null : null, + price, + allergens: allergensIdx >= 0 ? splitCodes(c[allergensIdx]) : [], + additives: additivesIdx >= 0 ? splitCodes(c[additivesIdx]) : [], + }); + }); + + let created = 0; + let updated = 0; + if (rows.length > 0) { + await withTransaction(async (client) => { + for (const row of rows) { + // Gerichtsnamen sind nicht eindeutig (keine DB-Constraint) – bei + // mehreren Treffern lieber überspringen als versehentlich alle + // gleichnamigen Gerichte mit denselben Werten zu überschreiben. + const existing = await client.query<{ id: string }>( + "select id from dishes where name = $1", + [row.name] + ); + if (existing.rows.length > 1) { + skipped.push({ + row: row.rowNum, + reason: `„${row.name}“ ist mehrdeutig (mehrere Gerichte mit diesem Namen) – übersprungen`, + }); + continue; + } + if (existing.rows.length === 1) { + await client.query( + `update dishes set name_en = $1, name_pl = $2, price = $3, allergens = $4, additives = $5 + where id = $6`, + [row.name_en, row.name_pl, row.price, row.allergens, row.additives, existing.rows[0].id] + ); + updated++; + } else { + await client.query( + `insert into dishes (name, name_en, name_pl, price, allergens, additives) + values ($1, $2, $3, $4, $5, $6)`, + [row.name, row.name_en, row.name_pl, row.price, row.allergens, row.additives] + ); + created++; + } + } + }); + } + + return NextResponse.json({ created, updated, skipped }); + } catch (err) { + const res = authErrorResponse(err); + if (res) return res; + throw err; + } +} diff --git a/app/feed.xml/route.ts b/app/feed.xml/route.ts index 7e36011..872a7d8 100644 --- a/app/feed.xml/route.ts +++ b/app/feed.xml/route.ts @@ -80,12 +80,20 @@ export async function GET() { ${appUrl}/feed.xml#${iso} ${pubDate} + `; }) .join("\n"); + // "description" gilt bei vielen Feed-Readern (Thunderbird, Outlook, + // etliche Apps) als reine Kurzfassung/Vorschau und wird eigenmächtig + // gekürzt – dadurch war z. B. bei mehreren Essenszeiten pro Tag oft nur + // die erste sichtbar. "content:encoded" (RSS-Content-Modul) gilt als + // vollständiger Artikeltext und wird von den meisten Readern + // ungekürzt angezeigt, wenn vorhanden – redundant zu description für + // Reader ohne Unterstützung des Moduls. const xml = ` - + ${escapeXml(SITE_TITLE)} – Speiseplan ${appUrl} diff --git a/components/CodeListManager.tsx b/components/CodeListManager.tsx index 0529ad4..1619cbc 100644 --- a/components/CodeListManager.tsx +++ b/components/CodeListManager.tsx @@ -34,6 +34,8 @@ export default function CodeListManager({ namePl: string; } | null>(null); const [error, setError] = useState(null); + const [notice, setNotice] = useState(null); + const [ioBusy, setIoBusy] = useState(false); const [translateState, setTranslateState] = useState<"idle" | "busy" | "done">( "idle" ); @@ -125,12 +127,77 @@ export default function CodeListManager({ } } + async function exportItems() { + setError(null); + setNotice(null); + const res = await fetch(`/api/${table}/export`); + if (!res.ok) { + setError(formatMessage(dict.codeList.exportError, { message: String(res.status) })); + return; + } + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = table === "allergens" ? "allergene.csv" : "zusatzstoffe.csv"; + a.click(); + URL.revokeObjectURL(url); + } + + async function importItems(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + e.target.value = ""; + if (!file) return; + setError(null); + setNotice(null); + setIoBusy(true); + const fd = new FormData(); + fd.append("file", file); + const res = await fetch(`/api/${table}/import`, { method: "POST", body: fd }); + setIoBusy(false); + if (!res.ok) { + setError(formatMessage(dict.codeList.importError, { message: String(res.status) })); + return; + } + const data = (await res.json()) as { + created: number; + updated: number; + skipped: { row: number; reason: string }[]; + }; + let msg = formatMessage(dict.codeList.importSuccess, { + created: data.created, + updated: data.updated, + }); + if (data.skipped.length > 0) { + const reasons = data.skipped + .map((s) => formatMessage(dict.codeList.importSkippedRow, s)) + .join("; "); + msg += + " " + formatMessage(dict.codeList.importSkipped, { count: data.skipped.length, reasons }); + } + setNotice(msg); + await load(); + } + return (

{title}

+ + +