feat(customers): add controlled CSV and XLSX customer import
This commit is contained in:
parent
613ffdc8b7
commit
82e03877b3
566 changed files with 2752 additions and 991 deletions
|
|
@ -0,0 +1,288 @@
|
|||
"use client";
|
||||
|
||||
import { AlertTriangle, CheckCircle2, FileUp, RotateCcw, UploadCloud } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useAuth } from "@/components/auth";
|
||||
|
||||
type ImportAction = "NEU_ANLEGEN" | "BESTEHENDEN_AKTUALISIEREN" | "UEBERSPRINGEN" | "ERROR";
|
||||
|
||||
type PreviewRow = {
|
||||
row_number: number;
|
||||
source: Record<string, string>;
|
||||
recognized: {
|
||||
customer: Record<string, string | null>;
|
||||
location: Record<string, string | null>;
|
||||
contact: Record<string, string | null>;
|
||||
};
|
||||
errors: string[];
|
||||
action: ImportAction;
|
||||
message: string;
|
||||
matches: Record<string, { id: string; name: string; external_id?: string | null; postal_code?: string | null; city?: string | null }>;
|
||||
allowed_actions: ImportAction[];
|
||||
};
|
||||
|
||||
type PreviewResult = {
|
||||
columns: string[];
|
||||
target_fields: Record<string, string>;
|
||||
mapping: Record<string, string>;
|
||||
rows: PreviewRow[];
|
||||
summary: Record<string, number>;
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
type ConfirmResult = {
|
||||
summary: Record<string, number>;
|
||||
rows: { row_number: number; customer_name?: string; action: ImportAction; result: string; message: string }[];
|
||||
};
|
||||
|
||||
const actionLabels: Record<ImportAction, string> = {
|
||||
NEU_ANLEGEN: "Neu anlegen",
|
||||
BESTEHENDEN_AKTUALISIEREN: "Aktualisieren",
|
||||
UEBERSPRINGEN: "Überspringen",
|
||||
ERROR: "Fehler"
|
||||
};
|
||||
|
||||
const filterOptions = [
|
||||
{ value: "all", label: "Alle" },
|
||||
{ value: "NEU_ANLEGEN", label: "Neu" },
|
||||
{ value: "BESTEHENDEN_AKTUALISIEREN", label: "Aktualisieren" },
|
||||
{ value: "duplicates", label: "Mögliche Dubletten" },
|
||||
{ value: "ERROR", label: "Fehler" },
|
||||
{ value: "UEBERSPRINGEN", label: "Überspringen" }
|
||||
];
|
||||
|
||||
async function readError(response: Response, fallback: string) {
|
||||
try {
|
||||
const data = (await response.json()) as { detail?: string };
|
||||
return data.detail ?? fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export default function CustomerImportPage() {
|
||||
const { user } = useAuth();
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [preview, setPreview] = useState<PreviewResult | null>(null);
|
||||
const [mapping, setMapping] = useState<Record<string, string>>({});
|
||||
const [rowActions, setRowActions] = useState<Record<string, ImportAction>>({});
|
||||
const [filter, setFilter] = useState("all");
|
||||
const [ignoreEmptyValues, setIgnoreEmptyValues] = useState(true);
|
||||
const [result, setResult] = useState<ConfirmResult | null>(null);
|
||||
const [loading, setLoading] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const all = preview?.rows ?? [];
|
||||
if (filter === "all") return all;
|
||||
if (filter === "duplicates") return all.filter((row) => row.matches && Object.keys(row.matches).length > 0 && row.action === "UEBERSPRINGEN");
|
||||
return all.filter((row) => (rowActions[String(row.row_number)] ?? row.action) === filter);
|
||||
}, [filter, preview?.rows, rowActions]);
|
||||
|
||||
if (user?.role !== "admin") {
|
||||
return <div className="rounded-lg border border-border bg-surface p-6 text-text-light">Nur fuer Administratoren verfuegbar.</div>;
|
||||
}
|
||||
|
||||
async function uploadPreview(nextMapping?: Record<string, string>) {
|
||||
if (!file) {
|
||||
setError("Bitte zuerst eine Datei auswählen.");
|
||||
return;
|
||||
}
|
||||
setLoading("preview");
|
||||
setError("");
|
||||
setResult(null);
|
||||
const body = new FormData();
|
||||
body.set("file", file);
|
||||
if (nextMapping) body.set("mapping", JSON.stringify(nextMapping));
|
||||
const response = await fetch("/api/customer-imports/preview", { method: "POST", body, credentials: "include" });
|
||||
if (!response.ok) {
|
||||
setError(await readError(response, "Importvorschau konnte nicht erzeugt werden."));
|
||||
setLoading("");
|
||||
return;
|
||||
}
|
||||
const data = (await response.json()) as PreviewResult;
|
||||
setPreview(data);
|
||||
setMapping(data.mapping);
|
||||
setRowActions(Object.fromEntries(data.rows.map((row) => [String(row.row_number), row.action])));
|
||||
setLoading("");
|
||||
}
|
||||
|
||||
async function confirmImport() {
|
||||
if (!file || !preview) return;
|
||||
setLoading("confirm");
|
||||
setError("");
|
||||
const body = new FormData();
|
||||
body.set("file", file);
|
||||
body.set("mapping", JSON.stringify(mapping));
|
||||
body.set("row_actions", JSON.stringify(rowActions));
|
||||
body.set("ignore_empty_values", String(ignoreEmptyValues));
|
||||
const response = await fetch("/api/customer-imports/confirm", { method: "POST", body, credentials: "include" });
|
||||
if (!response.ok) {
|
||||
setError(await readError(response, "Kundenimport konnte nicht ausgeführt werden."));
|
||||
setLoading("");
|
||||
return;
|
||||
}
|
||||
setResult((await response.json()) as ConfirmResult);
|
||||
setLoading("");
|
||||
}
|
||||
|
||||
function reset() {
|
||||
setFile(null);
|
||||
setPreview(null);
|
||||
setMapping({});
|
||||
setRowActions({});
|
||||
setResult(null);
|
||||
setError("");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 pb-10">
|
||||
<header className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-semibold text-text">Kunden importieren</h1>
|
||||
<p className="mt-2 text-text-light">CSV- oder XLSX-Kundenstamm kontrolliert prüfen und übernehmen.</p>
|
||||
</div>
|
||||
<Link href="/customers" className="btn btn-secondary h-12">Zur Kundenliste</Link>
|
||||
</header>
|
||||
|
||||
{error ? <div className="rounded-lg border border-danger/40 bg-danger/10 p-4 text-sm text-danger">{error}</div> : null}
|
||||
|
||||
<section className="rounded-lg border border-border bg-surface p-5 shadow-soft">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="rounded-lg bg-accent/20 p-3 text-primary"><UploadCloud className="h-6 w-6" /></div>
|
||||
<div className="flex-1">
|
||||
<h2 className="text-xl font-semibold">1. Datei hochladen</h2>
|
||||
<p className="mt-1 text-sm text-text-light">Erlaubt sind UTF-8 CSV und XLSX bis 5 MB. Der Upload verändert noch keine Daten.</p>
|
||||
<label className="mt-4 flex min-h-32 cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed border-primary/40 bg-background p-5 text-center transition hover:border-primary hover:bg-accent/10">
|
||||
<FileUp className="h-7 w-7 text-primary" />
|
||||
<span className="mt-2 font-semibold">{file ? file.name : "Datei auswählen oder hier ablegen"}</span>
|
||||
<span className="text-sm text-text-light">CSV oder XLSX</span>
|
||||
<input
|
||||
type="file"
|
||||
accept=".csv,.xlsx,text/csv,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
className="sr-only"
|
||||
onChange={(event) => {
|
||||
setFile(event.target.files?.[0] ?? null);
|
||||
setPreview(null);
|
||||
setResult(null);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<div className="mt-4 flex flex-wrap gap-3">
|
||||
<button type="button" className="btn btn-primary h-12" disabled={!file || loading === "preview"} onClick={() => uploadPreview()}>
|
||||
{loading === "preview" ? <span className="spinner" /> : null}
|
||||
Vorschau vorbereiten
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary h-12" onClick={reset}><RotateCcw className="h-4 w-4" /> Zurücksetzen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{preview ? (
|
||||
<>
|
||||
<section className="rounded-lg border border-border bg-surface p-5 shadow-soft">
|
||||
<h2 className="text-xl font-semibold">2. Spalten zuordnen</h2>
|
||||
<p className="mt-1 text-sm text-text-light">Bitte automatische Zuordnung prüfen und bei Bedarf ändern.</p>
|
||||
{preview.warnings.map((warning) => (
|
||||
<div key={warning} className="mt-3 flex items-center gap-2 rounded-lg border border-warning/40 bg-warning/10 p-3 text-sm text-[#7A5A00]">
|
||||
<AlertTriangle className="h-4 w-4" /> {warning}
|
||||
</div>
|
||||
))}
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-2">
|
||||
{preview.columns.map((column) => (
|
||||
<label key={column} className="grid gap-2 rounded-lg border border-border p-3">
|
||||
<span className="text-sm font-semibold">{column}</span>
|
||||
<select
|
||||
value={mapping[column] ?? "ignore"}
|
||||
onChange={(event) => setMapping((current) => ({ ...current, [column]: event.target.value }))}
|
||||
className="h-11 rounded-lg border border-border px-3"
|
||||
>
|
||||
{Object.entries(preview.target_fields).map(([value, label]) => <option key={value} value={value}>{label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<button type="button" className="btn btn-secondary mt-4 h-12" disabled={loading === "preview"} onClick={() => uploadPreview(mapping)}>
|
||||
{loading === "preview" ? <span className="spinner" /> : null}
|
||||
Vorschau mit Zuordnung aktualisieren
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg border border-border bg-surface p-5 shadow-soft">
|
||||
<h2 className="text-xl font-semibold">3. Importvorschau prüfen</h2>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{filterOptions.map((item) => (
|
||||
<button key={item.value} type="button" onClick={() => setFilter(item.value)} className={`rounded-lg border px-3 py-2 text-sm font-semibold transition hover:shadow-soft ${filter === item.value ? "border-primary bg-primary text-white" : "border-border bg-white text-text"}`}>{item.label}</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-4 overflow-x-auto">
|
||||
<table className="min-w-full text-left text-sm">
|
||||
<thead className="bg-background text-xs uppercase text-text-light">
|
||||
<tr><th className="px-4 py-3">Zeile</th><th className="px-4 py-3">Kunde</th><th className="px-4 py-3">Standort</th><th className="px-4 py-3">Ansprechpartner</th><th className="px-4 py-3">Hinweis</th><th className="px-4 py-3">Aktion</th></tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{rows.map((row) => {
|
||||
const currentAction = rowActions[String(row.row_number)] ?? row.action;
|
||||
return (
|
||||
<tr key={row.row_number} className="hover:bg-accent/10">
|
||||
<td className="px-4 py-3">{row.row_number}</td>
|
||||
<td className="px-4 py-3"><strong>{row.recognized.customer.name || "nicht erkannt"}</strong><br /><span className="text-text-light">{row.recognized.customer.external_id || row.recognized.customer.email}</span></td>
|
||||
<td className="px-4 py-3">{row.recognized.location.name}<br /><span className="text-text-light">{row.recognized.location.postal_code} {row.recognized.location.city}</span></td>
|
||||
<td className="px-4 py-3">{row.recognized.contact.full_name || "nicht erfasst"}</td>
|
||||
<td className="px-4 py-3">{row.errors.length ? <span className="text-danger">{row.errors.join(", ")}</span> : row.message}</td>
|
||||
<td className="px-4 py-3">
|
||||
<select value={currentAction} disabled={row.allowed_actions.length <= 1} onChange={(event) => setRowActions((current) => ({ ...current, [String(row.row_number)]: event.target.value as ImportAction }))} className="h-10 rounded-lg border border-border px-3">
|
||||
{row.allowed_actions.map((action) => <option key={action} value={action}>{actionLabels[action]}</option>)}
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg border border-border bg-surface p-5 shadow-soft">
|
||||
<h2 className="text-xl font-semibold">4. Import bestätigen</h2>
|
||||
<div className="mt-3 grid gap-3 sm:grid-cols-5">
|
||||
<Stat label="Zeilen" value={preview.summary.total} />
|
||||
<Stat label="Neu" value={preview.summary.new} />
|
||||
<Stat label="Aktualisieren" value={preview.summary.update} />
|
||||
<Stat label="Dubletten" value={preview.summary.duplicates} />
|
||||
<Stat label="Fehler" value={preview.summary.errors} />
|
||||
</div>
|
||||
<label className="mt-4 flex items-center gap-3 text-sm font-semibold">
|
||||
<input type="checkbox" checked={ignoreEmptyValues} onChange={(event) => setIgnoreEmptyValues(event.target.checked)} className="h-5 w-5" />
|
||||
Leere Importfelder ignorieren
|
||||
</label>
|
||||
<button type="button" className="btn btn-primary mt-4 h-12" disabled={loading === "confirm" || !preview.rows.length} onClick={confirmImport}>
|
||||
{loading === "confirm" ? <span className="spinner" /> : <CheckCircle2 className="h-4 w-4" />}
|
||||
{loading === "confirm" ? "Import läuft..." : "Kunden jetzt importieren"}
|
||||
</button>
|
||||
</section>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{result ? (
|
||||
<section className="rounded-lg border border-success/40 bg-success/10 p-5 shadow-soft">
|
||||
<h2 className="text-xl font-semibold text-success">Import abgeschlossen</h2>
|
||||
<div className="mt-3 grid gap-3 sm:grid-cols-3">
|
||||
<Stat label="Kunden neu" value={result.summary.created_customers} />
|
||||
<Stat label="Kunden aktualisiert" value={result.summary.updated_customers} />
|
||||
<Stat label="Fehler" value={result.summary.error_rows} />
|
||||
<Stat label="Standorte neu" value={result.summary.created_locations} />
|
||||
<Stat label="Ansprechpartner neu" value={result.summary.created_contacts} />
|
||||
<Stat label="Übersprungen" value={result.summary.skipped_rows} />
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value }: { label: string; value?: number }) {
|
||||
return <div className="rounded-lg border border-border bg-white p-3"><div className="text-2xl font-semibold">{value ?? 0}</div><div className="text-sm text-text-light">{label}</div></div>;
|
||||
}
|
||||
|
|
@ -6,6 +6,8 @@ import { Customer } from "@/lib/api";
|
|||
|
||||
const schema = z.object({
|
||||
customer_type: z.enum(["practice", "clinic"]),
|
||||
source_system: z.string().optional().nullable(),
|
||||
external_id: z.string().optional().nullable(),
|
||||
name: z.string().min(2),
|
||||
street: z.string().optional().nullable(),
|
||||
postal_code: z.string().optional().nullable(),
|
||||
|
|
@ -25,6 +27,7 @@ export default function CustomersPage() {
|
|||
endpoint="/customers"
|
||||
columns={[
|
||||
{ key: "name", label: "Name" },
|
||||
{ key: "external_id", label: "Kundennr." },
|
||||
{ key: "customer_type", label: "Typ" },
|
||||
{ key: "city", label: "Ort" },
|
||||
{ key: "phone", label: "Telefon" },
|
||||
|
|
@ -32,6 +35,8 @@ export default function CustomersPage() {
|
|||
]}
|
||||
fields={[
|
||||
{ name: "customer_type", label: "Typ", type: "select", options: [{ label: "Praxis", value: "practice" }, { label: "Klinik", value: "clinic" }] },
|
||||
{ name: "source_system", label: "Quellsystem" },
|
||||
{ name: "external_id", label: "Kundennummer" },
|
||||
{ name: "name", label: "Name", required: true },
|
||||
{ name: "street", label: "Adresse" },
|
||||
{ name: "postal_code", label: "PLZ" },
|
||||
|
|
@ -43,8 +48,7 @@ export default function CustomersPage() {
|
|||
{ name: "notes", label: "Bemerkungen", type: "textarea" }
|
||||
]}
|
||||
schema={schema}
|
||||
emptyValues={{ customer_type: "practice", name: "", street: "", postal_code: "", city: "", phone: "", email: "", hygiene_officer: "", quality_manager: "", notes: "" }}
|
||||
emptyValues={{ customer_type: "practice", source_system: "", external_id: "", name: "", street: "", postal_code: "", city: "", phone: "", email: "", hygiene_officer: "", quality_manager: "", notes: "" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
import { cookies } from "next/headers";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { AUTH_COOKIE_NAME } from "@/lib/auth";
|
||||
|
||||
const mercuryBase = `${process.env.MERCURY_INTERNAL_URL ?? "http://mercury-api:8000"}/api/v1`;
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get(AUTH_COOKIE_NAME)?.value;
|
||||
if (!token) {
|
||||
return NextResponse.json({ detail: "Not authenticated" }, { status: 401 });
|
||||
}
|
||||
const response = await fetch(`${mercuryBase}/customer-imports/confirm`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: await request.formData(),
|
||||
cache: "no-store"
|
||||
});
|
||||
const data = await response.json();
|
||||
return NextResponse.json(data, { status: response.status });
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
import { cookies } from "next/headers";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { AUTH_COOKIE_NAME } from "@/lib/auth";
|
||||
|
||||
const mercuryBase = `${process.env.MERCURY_INTERNAL_URL ?? "http://mercury-api:8000"}/api/v1`;
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get(AUTH_COOKIE_NAME)?.value;
|
||||
if (!token) {
|
||||
return NextResponse.json({ detail: "Not authenticated" }, { status: 401 });
|
||||
}
|
||||
const response = await fetch(`${mercuryBase}/customer-imports/preview`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: await request.formData(),
|
||||
cache: "no-store"
|
||||
});
|
||||
const data = await response.json();
|
||||
return NextResponse.json(data, { status: response.status });
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue