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:
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 },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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[];
|
||||||
|
}
|
||||||
Reference in new issue
Block a user