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:
2026-07-19 23:10:36 +02:00
co-authored by Claude Sonnet 5
parent 5874eb31b6
commit 474467811c
15 changed files with 658 additions and 5 deletions
+68
View File
@@ -40,6 +40,8 @@ export default function DishManager({
const [additives, setAdditives] = useState<CodeItem[]>([]);
const [form, setForm] = useState<DishForm | null>(null);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [ioBusy, setIoBusy] = useState(false);
const [loading, setLoading] = useState(true);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [bulkBusy, setBulkBusy] = useState(false);
@@ -219,6 +221,58 @@ export default function DishManager({
}
}
async function exportDishes() {
setError(null);
setNotice(null);
const res = await fetch("/api/dishes/export");
if (!res.ok) {
setError(formatMessage(dict.dishManager.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 = "gerichte.csv";
a.click();
URL.revokeObjectURL(url);
}
async function importDishes(e: React.ChangeEvent<HTMLInputElement>) {
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/dishes/import", { method: "POST", body: fd });
setIoBusy(false);
if (!res.ok) {
setError(formatMessage(dict.dishManager.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.dishManager.importSuccess, {
created: data.created,
updated: data.updated,
});
if (data.skipped.length > 0) {
const reasons = data.skipped
.map((s) => formatMessage(dict.dishManager.importSkippedRow, s))
.join("; ");
msg +=
" " + formatMessage(dict.dishManager.importSkipped, { count: data.skipped.length, reasons });
}
setNotice(msg);
await load();
}
const allSelected =
visible.length > 0 && visible.every((d) => selected.has(d.id));
@@ -228,6 +282,19 @@ export default function DishManager({
<h1 className="text-xl font-bold text-[var(--c-primary)] mr-auto">
{dict.dishManager.title}
</h1>
<button type="button" className="btn btn-neutral" onClick={exportDishes}>
{dict.dishManager.exportButton}
</button>
<label className="btn btn-neutral cursor-pointer">
{dict.dishManager.importButton}
<input
type="file"
accept=".csv,text/csv"
className="hidden"
onChange={importDishes}
disabled={ioBusy}
/>
</label>
<button
type="button"
className="btn btn-add"
@@ -245,6 +312,7 @@ export default function DishManager({
{error}
</p>
)}
{notice && <p className="mb-3 text-sm text-green-700">{notice}</p>}
{form && (
<form onSubmit={save} className="card mb-4 space-y-4">