feat(customers): add initial admin bootstrap and csv import

This commit is contained in:
DS | Schubert 2026-07-03 09:14:56 +02:00
parent c816e9869d
commit ecab0fe6b6
27 changed files with 1197 additions and 34 deletions

View file

@ -1,31 +1,51 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import LogoutButton from "@/components/LogoutButton";
import { api } from "@/lib/api";
import type { CurrentUser } from "@/types/rbac";
export default function Header() {
const [currentUser, setCurrentUser] = useState<CurrentUser | null>(null);
return (
useEffect(() => {
let active = true;
<header className="h-20 bg-white border-b flex items-center justify-between px-8">
api.get<CurrentUser>("/me")
.then((response) => {
if (active) {
setCurrentUser(response.data);
}
})
.catch(() => {
if (active) {
setCurrentUser(null);
}
});
<h1 className="text-3xl font-bold">
return () => {
active = false;
};
}, []);
Dashboard
const displayName = useMemo(() => {
if (!currentUser) {
return "Benutzer";
}
</h1>
const fullName = `${currentUser.first_name} ${currentUser.last_name}`.trim();
return fullName || currentUser.username || "Benutzer";
}, [currentUser]);
<div className="flex items-center gap-4">
<span className="font-semibold">
admin.schubert
</span>
<LogoutButton />
</div>
</header>
);
return (
<header className="flex h-20 items-center justify-between border-b bg-white px-8">
<h1 className="text-3xl font-bold">Dashboard</h1>
<div className="flex items-center gap-4">
<span className="font-semibold">{displayName}</span>
<LogoutButton />
</div>
</header>
);
}

View file

@ -0,0 +1,177 @@
"use client";
import { Download, FileUp, Play, Upload } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import ImportPreviewTable from "@/components/customers/ImportPreviewTable";
import ImportSummaryCard from "@/components/customers/ImportSummaryCard";
import { Button, buttonVariants } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { api } from "@/lib/api";
import type {
CustomerImportCommitResponse,
CustomerImportMode,
CustomerImportPreviewResponse,
} from "@/types/customer";
type Props = {
open: boolean;
onOpenChange: (open: boolean) => void;
onCommitted: (result: CustomerImportCommitResponse) => void;
onError: (message: string) => void;
};
function errorMessage(error: unknown) {
if (typeof error === "object" && error !== null && "response" in error) {
const response = (error as { response?: { data?: { detail?: string } } }).response;
return response?.data?.detail ?? "Import konnte nicht ausgeführt werden";
}
return "Import konnte nicht ausgeführt werden";
}
function buildFormData(file: File, mode: CustomerImportMode) {
const formData = new FormData();
formData.append("mode", mode);
formData.append("file", file);
return formData;
}
export default function CustomerImportDialog({ open, onOpenChange, onCommitted, onError }: Props) {
const [file, setFile] = useState<File | null>(null);
const [mode, setMode] = useState<CustomerImportMode>("upsert");
const [preview, setPreview] = useState<CustomerImportPreviewResponse | null>(null);
const [pending, setPending] = useState(false);
const [serverError, setServerError] = useState("");
async function runPreview() {
if (!file) {
setServerError("Bitte CSV-Datei auswählen");
return;
}
setPending(true);
setServerError("");
try {
const response = await api.post<CustomerImportPreviewResponse>(
"/customers/import/preview",
buildFormData(file, mode),
);
setPreview(response.data);
} catch (error) {
const message = errorMessage(error);
setServerError(message);
onError(message);
} finally {
setPending(false);
}
}
async function commitImport() {
if (!file || !preview || preview.summary.valid_rows === 0) {
return;
}
setPending(true);
setServerError("");
try {
const response = await api.post<CustomerImportCommitResponse>(
"/customers/import/commit",
buildFormData(file, mode),
);
onCommitted(response.data);
setPreview(null);
setFile(null);
onOpenChange(false);
} catch (error) {
const message = errorMessage(error);
setServerError(message);
onError(message);
} finally {
setPending(false);
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-5xl">
<DialogHeader>
<DialogTitle>Kunden importieren</DialogTitle>
<DialogDescription>CSV-Datei prüfen und anschließend gültige Zeilen übernehmen.</DialogDescription>
</DialogHeader>
<div className="grid gap-4">
<div className="grid gap-4 lg:grid-cols-[1fr_14rem_auto] lg:items-end">
<div className="grid gap-2">
<Label htmlFor="customer-import-file">CSV-Datei</Label>
<input
id="customer-import-file"
type="file"
accept=".csv,text/csv"
className="h-10 rounded-lg border border-input bg-white px-3 py-2 text-sm"
onChange={(event) => {
setFile(event.target.files?.[0] ?? null);
setPreview(null);
setServerError("");
}}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="customer-import-mode">Importmodus</Label>
<select
id="customer-import-mode"
value={mode}
className="h-10 rounded-lg border border-input bg-white px-3 text-sm"
onChange={(event) => {
setMode(event.target.value as CustomerImportMode);
setPreview(null);
}}
>
<option value="upsert">Upsert</option>
<option value="create_only">Nur erstellen</option>
<option value="update_existing">Bestehende aktualisieren</option>
</select>
</div>
<Link href="/api/customers/import/template" className={buttonVariants({ variant: "outline", size: "default" })}>
<Download size={16} />
Vorlage
</Link>
</div>
{serverError && <p className="rounded-lg bg-red-50 p-3 text-sm text-red-700">{serverError}</p>}
{preview && (
<div className="grid gap-4">
<ImportSummaryCard summary={preview.summary} />
<ImportPreviewTable rows={preview.rows} />
</div>
)}
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={pending}>
Schließen
</Button>
<Button type="button" variant="outline" onClick={runPreview} disabled={pending || !file}>
<Play size={16} />
Preview starten
</Button>
<Button type="button" onClick={commitImport} disabled={pending || !preview || preview.summary.valid_rows === 0}>
{pending ? <FileUp size={16} /> : <Upload size={16} />}
Import übernehmen
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,56 @@
import type { CustomerImportPreviewRow } from "@/types/customer";
const actionLabels = {
create: "Create",
update: "Update",
skip: "Skip",
error: "Error",
};
const actionClasses = {
create: "bg-emerald-50 text-emerald-700",
update: "bg-blue-50 text-blue-700",
skip: "bg-slate-100 text-slate-600",
error: "bg-red-50 text-red-700",
};
function issueText(row: CustomerImportPreviewRow) {
const issues = [...row.errors, ...row.warnings];
if (issues.length === 0) {
return "-";
}
return issues.map((issue) => `${issue.field || "Zeile"}: ${issue.message}`).join(" · ");
}
export default function ImportPreviewTable({ rows }: { rows: CustomerImportPreviewRow[] }) {
return (
<div className="max-h-80 overflow-auto rounded-lg border">
<table className="w-full min-w-[720px] border-collapse text-sm">
<thead className="sticky top-0 bg-slate-50 text-left text-xs uppercase text-slate-500">
<tr>
<th className="px-3 py-2">Zeile</th>
<th className="px-3 py-2">Aktion</th>
<th className="px-3 py-2">Kundennummer</th>
<th className="px-3 py-2">Firma</th>
<th className="px-3 py-2">Hinweise</th>
</tr>
</thead>
<tbody className="divide-y bg-white">
{rows.map((row) => (
<tr key={row.row}>
<td className="px-3 py-2 font-medium">{row.row}</td>
<td className="px-3 py-2">
<span className={`rounded-full px-2 py-1 text-xs font-semibold ${actionClasses[row.action]}`}>
{actionLabels[row.action]}
</span>
</td>
<td className="px-3 py-2">{row.customer_number || "-"}</td>
<td className="px-3 py-2">{row.company_name || "-"}</td>
<td className="px-3 py-2 text-slate-600">{issueText(row)}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}

View file

@ -0,0 +1,24 @@
import type { CustomerImportSummary } from "@/types/customer";
const summaryItems = [
["Gesamt", "total_rows"],
["Gültig", "valid_rows"],
["Fehler", "error_count"],
["Warnungen", "warning_count"],
["Creates", "create_count"],
["Updates", "update_count"],
["Skips", "skip_count"],
] as const;
export default function ImportSummaryCard({ summary }: { summary: CustomerImportSummary }) {
return (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4 lg:grid-cols-7">
{summaryItems.map(([label, key]) => (
<div key={key} className="rounded-lg border bg-white p-3">
<p className="text-xs text-slate-500">{label}</p>
<p className="mt-1 text-xl font-semibold text-slate-950">{summary[key]}</p>
</div>
))}
</div>
);
}