feat(frontend): CSV-Import-Seite mit Ergebnisbericht (Modul 33)

Neue Seite /settings/import (nur Administrator, Sidebar-Link unter
Verwaltung): Datentyp wählen (Bienenstände/Völker/Königinnen), CSV-Datei
hochladen, danach Ergebnisbericht mit Zusammenfassung (erstellt/
übersprungen/Fehler) und Zeile-für-Zeile-Tabelle inkl. Statusbadge und
Fehlermeldung.

Live gegen das Backend verifiziert: Upload einer CSV mit einer gültigen
und einer ungültigen Zeile erzeugt korrekt 1 erstellt / 1 Fehler mit
Validierungsmeldung, der neu angelegte Bienenstand erscheint danach in
der regulären Bienenstände-Liste. Zero Konsolenfehler.
This commit is contained in:
Nick Szaunig committed 2026-08-24 23:07:07 +02:00
1 parent 4f2f31c05f
commit dfea017f75
4 files changed
+206

No files matched your search

@@ -0,0 +1,178 @@
"use client";
import { ChangeEvent, useState } from "react";
import { Upload } from "lucide-react";
import { useMutation } from "@tanstack/react-query";
import { toast } from "sonner";
import { importCsv } from "@/lib/import/api";
import { ImportEntity, ImportReport, ImportRowStatus } from "@/lib/import/types";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { PageHeader } from "@/components/page-header";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
const ENTITY_LABELS: Record<ImportEntity, string> = {
apiaries: "Bienenstände",
colonies: "Völker",
queens: "Königinnen",
};
const ENTITY_COLUMNS: Record<ImportEntity, string> = {
apiaries: "name, description, location, latitude, longitude, address, notes",
colonies:
"code, apiaryName, colonyType, hiveType, originText, strength, broodStatus, feedStatus, healthStatus, notes",
queens:
"code, hatchYear, breed, origin, breeder, markingColor, markingNumber, matingType, matingStatus, inseminationData, motherLine, fatherLine, notes",
};
const ROW_STATUS_VARIANT: Record<ImportRowStatus, "default" | "secondary" | "destructive"> = {
created: "default",
skipped: "secondary",
error: "destructive",
};
const ROW_STATUS_LABELS: Record<ImportRowStatus, string> = {
created: "Erstellt",
skipped: "Übersprungen",
error: "Fehler",
};
export default function ImportPage() {
const [entity, setEntity] = useState<ImportEntity>("apiaries");
const [file, setFile] = useState<File | null>(null);
const [report, setReport] = useState<ImportReport | null>(null);
const mutation = useMutation({
mutationFn: () => {
if (!file) {
throw new Error("Keine Datei ausgewählt");
}
return importCsv(entity, file);
},
onSuccess: (result) => {
setReport(result);
toast.success(
`Import abgeschlossen: ${result.created} erstellt, ${result.skipped} übersprungen, ${result.errors} Fehler`,
);
},
onError: () => toast.error("Import fehlgeschlagen"),
});
function handleFileChange(event: ChangeEvent<HTMLInputElement>) {
setFile(event.target.files?.[0] ?? null);
}
function handleSubmit() {
if (!file) {
toast.error("Bitte eine CSV-Datei auswählen");
return;
}
mutation.mutate();
}
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<PageHeader
icon={Upload}
title="CSV-Import"
description="Stammdaten aus einer CSV-Datei anlegen"
gradient="var(--gradient-sky)"
/>
<Card className="max-w-lg">
<CardHeader>
<CardTitle>Datei hochladen</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4 text-sm">
<div className="flex flex-col gap-2">
<Label htmlFor="import-entity">Datentyp</Label>
<Select
value={entity}
onValueChange={(value) => setEntity((value ?? "apiaries") as ImportEntity)}
>
<SelectTrigger id="import-entity">
<SelectValue>{ENTITY_LABELS[entity]}</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="apiaries">Bienenstände</SelectItem>
<SelectItem value="colonies">Völker</SelectItem>
<SelectItem value="queens">Königinnen</SelectItem>
</SelectContent>
</Select>
<p className="text-muted-foreground">Spalten: {ENTITY_COLUMNS[entity]}</p>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="import-file">CSV-Datei</Label>
<input
id="import-file"
type="file"
accept=".csv,text/csv"
onChange={handleFileChange}
className="text-sm file:mr-3 file:rounded-lg file:border-0 file:bg-muted file:px-3 file:py-2 file:text-sm file:font-medium"
/>
</div>
<Button type="button" disabled={mutation.isPending} onClick={handleSubmit}>
{mutation.isPending ? "Wird importiert…" : "Importieren"}
</Button>
</CardContent>
</Card>
{report ? (
<Card className="max-w-2xl">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Ergebnisbericht ({ENTITY_LABELS[report.entity]})</CardTitle>
<div className="flex flex-wrap gap-2">
<Badge variant="default">{report.created} erstellt</Badge>
<Badge variant="secondary">{report.skipped} übersprungen</Badge>
<Badge variant="destructive">{report.errors} Fehler</Badge>
</div>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Zeile</TableHead>
<TableHead>Status</TableHead>
<TableHead>Meldung</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{report.rows.map((row) => (
<TableRow key={row.row}>
<TableCell>{row.row}</TableCell>
<TableCell>
<Badge variant={ROW_STATUS_VARIANT[row.status]}>
{ROW_STATUS_LABELS[row.status]}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground">
{row.message ?? "—"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
) : null}
</main>
);
}
@@ -18,6 +18,7 @@ import {
Package, Package,
Search, Search,
ShoppingBag, ShoppingBag,
Upload,
Users, Users,
Warehouse, Warehouse,
Webhook, Webhook,
@@ -84,6 +85,7 @@ const ADMIN_GROUP: NavGroup = {
{ label: "Audit-Log", href: "/settings/audit-log", icon: History }, { label: "Audit-Log", href: "/settings/audit-log", icon: History },
{ label: "Webhooks", href: "/settings/webhooks", icon: Webhook }, { label: "Webhooks", href: "/settings/webhooks", icon: Webhook },
{ label: "Automatisierungen", href: "/settings/automation-rules", icon: Zap }, { label: "Automatisierungen", href: "/settings/automation-rules", icon: Zap },
{ label: "Import", href: "/settings/import", icon: Upload },
], ],
}; };
+8
View File
@@ -0,0 +1,8 @@
import { apiFetchFormData } from "@/lib/auth/api-client";
import { ImportEntity, ImportReport } from "./types";
export function importCsv(entity: ImportEntity, file: File): Promise<ImportReport> {
const formData = new FormData();
formData.append("file", file);
return apiFetchFormData<ImportReport>(`/import/${entity}`, formData);
}
+18
View File
@@ -0,0 +1,18 @@
export type ImportEntity = "apiaries" | "colonies" | "queens";
export type ImportRowStatus = "created" | "skipped" | "error";
export interface ImportRowResult {
row: number;
status: ImportRowStatus;
message?: string;
id?: string;
}
export interface ImportReport {
entity: ImportEntity;
totalRows: number;
created: number;
skipped: number;
errors: number;
rows: ImportRowResult[];
}