feat: CSV-Import/-Export für Gerichte/Allergene/Zusatzstoffe, RSS-Fix für mehrere Essenszeiten
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: <content:encoded> zusätzlich zu <description> 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user