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

@ -0,0 +1,14 @@
import { NextRequest } from "next/server";
import { assertSameOrigin } from "@/lib/server/request-guards";
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
export async function POST(request: NextRequest) {
const originError = assertSameOrigin(request);
if (originError) {
return originError;
}
return proxyHermesRequest(request, "/customers/import/commit");
}

View file

@ -0,0 +1,14 @@
import { NextRequest } from "next/server";
import { assertSameOrigin } from "@/lib/server/request-guards";
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
export async function POST(request: NextRequest) {
const originError = assertSameOrigin(request);
if (originError) {
return originError;
}
return proxyHermesRequest(request, "/customers/import/preview");
}

View file

@ -0,0 +1,40 @@
import { NextResponse } from "next/server";
const columns = [
"customer_number",
"company_name",
"legal_name",
"customer_type",
"status",
"industry",
"website",
"email",
"phone",
"tax_number",
"vat_id",
"notes",
"address_type",
"street",
"postal_code",
"city",
"state",
"country",
"address_is_primary",
"contact_first_name",
"contact_last_name",
"contact_position",
"contact_email",
"contact_phone",
"contact_mobile",
"contact_is_primary",
"contact_notes",
];
export async function GET() {
return new NextResponse(`${columns.join(";")}\n`, {
headers: {
"Content-Disposition": 'attachment; filename="kundenimport-vorlage.csv"',
"Content-Type": "text/csv; charset=utf-8",
},
});
}

View file

@ -2,7 +2,7 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { Edit, Eye, Plus, Trash2 } from "lucide-react";
import { Edit, Eye, Plus, Trash2, Upload } from "lucide-react";
import ConfirmDialog from "@/components/common/ConfirmDialog";
import DataTable, { type DataTableColumn } from "@/components/common/DataTable";
@ -10,11 +10,12 @@ import SearchInput from "@/components/common/SearchInput";
import { useToast } from "@/components/common/ToastProvider";
import { Button, buttonVariants } from "@/components/ui/button";
import CustomerFormDialog from "@/components/customers/CustomerFormDialog";
import CustomerImportDialog from "@/components/customers/CustomerImportDialog";
import CustomerStatusBadge from "@/components/customers/CustomerStatusBadge";
import { api } from "@/lib/api";
import { hasPermission } from "@/lib/permissions";
import type { CurrentUser } from "@/types/rbac";
import type { Customer, CustomerPayload, CustomerStatus, CustomerType } from "@/types/customer";
import type { Customer, CustomerImportCommitResponse, CustomerPayload, CustomerStatus, CustomerType } from "@/types/customer";
const pageSize = 10;
@ -64,6 +65,7 @@ export default function CustomersPage() {
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("desc");
const [page, setPage] = useState(1);
const [formOpen, setFormOpen] = useState(false);
const [importOpen, setImportOpen] = useState(false);
const [selectedCustomer, setSelectedCustomer] = useState<Customer | null>(null);
const [deleteCustomer, setDeleteCustomer] = useState<Customer | null>(null);
@ -122,6 +124,7 @@ export default function CustomersPage() {
const pageCount = Math.max(1, Math.ceil(filteredCustomers.length / pageSize));
const currentPage = Math.min(page, pageCount);
const pageCustomers = filteredCustomers.slice((currentPage - 1) * pageSize, currentPage * pageSize);
const canImportCustomers = hasPermission(currentUser, "customers.create") || hasPermission(currentUser, "customers.update");
const columns: DataTableColumn<Customer>[] = [
{ key: "customer_number", label: "Kundennummer", sortable: true, render: (customer) => customer.customer_number },
@ -221,6 +224,16 @@ export default function CustomersPage() {
}
}
async function handleImportCommitted(result: CustomerImportCommitResponse) {
showToast({
type: "success",
title: "Kundenimport abgeschlossen",
description: `${result.created} erstellt, ${result.updated} aktualisiert, ${result.skipped} übersprungen`,
});
setLoading(true);
await loadCustomers();
}
return (
<div className="space-y-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
@ -228,12 +241,20 @@ export default function CustomersPage() {
<h1 className="text-3xl font-bold text-slate-950">Kunden</h1>
<p className="mt-1 text-sm text-slate-500">{filteredCustomers.length} von {customers.length} Kunden</p>
</div>
{hasPermission(currentUser, "customers.create") && (
<Button type="button" onClick={openCreateDialog}>
<Plus size={16} />
Neuer Kunde
</Button>
)}
<div className="flex flex-wrap gap-2">
{canImportCustomers && (
<Button type="button" variant="outline" onClick={() => setImportOpen(true)}>
<Upload size={16} />
Import
</Button>
)}
{hasPermission(currentUser, "customers.create") && (
<Button type="button" onClick={openCreateDialog}>
<Plus size={16} />
Neuer Kunde
</Button>
)}
</div>
</div>
<div className="flex flex-col gap-3 rounded-lg border bg-white p-4 lg:flex-row lg:items-center">
@ -286,6 +307,13 @@ export default function CustomersPage() {
onSubmit={saveCustomer}
/>
<CustomerImportDialog
open={importOpen}
onOpenChange={setImportOpen}
onCommitted={handleImportCommitted}
onError={(message) => showToast({ type: "error", title: "Kundenimport fehlgeschlagen", description: message })}
/>
<ConfirmDialog
open={Boolean(deleteCustomer)}
title="Kunde löschen"