feat(customers): add initial admin bootstrap and csv import
This commit is contained in:
parent
c816e9869d
commit
ecab0fe6b6
27 changed files with 1197 additions and 34 deletions
14
frontend/athena/app/api/customers/import/commit/route.ts
Normal file
14
frontend/athena/app/api/customers/import/commit/route.ts
Normal 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");
|
||||
}
|
||||
14
frontend/athena/app/api/customers/import/preview/route.ts
Normal file
14
frontend/athena/app/api/customers/import/preview/route.ts
Normal 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");
|
||||
}
|
||||
40
frontend/athena/app/api/customers/import/template/route.ts
Normal file
40
frontend/athena/app/api/customers/import/template/route.ts
Normal 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",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
177
frontend/athena/components/customers/CustomerImportDialog.tsx
Normal file
177
frontend/athena/components/customers/CustomerImportDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
56
frontend/athena/components/customers/ImportPreviewTable.tsx
Normal file
56
frontend/athena/components/customers/ImportPreviewTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
24
frontend/athena/components/customers/ImportSummaryCard.tsx
Normal file
24
frontend/athena/components/customers/ImportSummaryCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -27,6 +27,8 @@ export async function proxyHermesRequest(
|
|||
}
|
||||
|
||||
let hermesResponse: Response;
|
||||
const isMultipart = request.headers.get("content-type")?.includes("multipart/form-data") ?? false;
|
||||
const hasBody = request.method !== "GET" && request.method !== "DELETE";
|
||||
|
||||
try {
|
||||
hermesResponse = await fetch(`${hermesUrl}${path}`, {
|
||||
|
|
@ -34,11 +36,13 @@ export async function proxyHermesRequest(
|
|||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
...(!isMultipart ? { "Content-Type": "application/json" } : {}),
|
||||
},
|
||||
body: request.method === "GET" || request.method === "DELETE"
|
||||
body: !hasBody
|
||||
? undefined
|
||||
: await request.text(),
|
||||
: isMultipart
|
||||
? await request.formData()
|
||||
: await request.text(),
|
||||
cache: "no-store",
|
||||
});
|
||||
} catch {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
export type CustomerStatus = "lead" | "active" | "inactive" | "blocked" | "archived";
|
||||
export type CustomerType = "company" | "private" | "public_sector" | "partner" | "supplier";
|
||||
export type AddressType = "billing" | "shipping" | "primary" | "other";
|
||||
export type CustomerImportMode = "create_only" | "update_existing" | "upsert";
|
||||
export type CustomerImportAction = "create" | "update" | "skip" | "error";
|
||||
|
||||
export interface CustomerAddress {
|
||||
id: number;
|
||||
|
|
@ -87,3 +89,46 @@ export type CustomerContactPayload = {
|
|||
is_primary: boolean;
|
||||
notes: string;
|
||||
};
|
||||
|
||||
export interface CustomerImportIssue {
|
||||
row: number;
|
||||
field: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface CustomerImportPreviewRow {
|
||||
row: number;
|
||||
customer_number: string;
|
||||
company_name: string;
|
||||
action: CustomerImportAction;
|
||||
errors: CustomerImportIssue[];
|
||||
warnings: CustomerImportIssue[];
|
||||
}
|
||||
|
||||
export interface CustomerImportSummary {
|
||||
total_rows: number;
|
||||
valid_rows: number;
|
||||
error_count: number;
|
||||
warning_count: number;
|
||||
duplicate_count: number;
|
||||
create_count: number;
|
||||
update_count: number;
|
||||
skip_count: number;
|
||||
}
|
||||
|
||||
export interface CustomerImportPreviewResponse {
|
||||
mode: CustomerImportMode;
|
||||
summary: CustomerImportSummary;
|
||||
rows: CustomerImportPreviewRow[];
|
||||
errors: CustomerImportIssue[];
|
||||
warnings: CustomerImportIssue[];
|
||||
duplicates: CustomerImportIssue[];
|
||||
}
|
||||
|
||||
export interface CustomerImportCommitResponse {
|
||||
mode: CustomerImportMode;
|
||||
summary: CustomerImportSummary;
|
||||
created: number;
|
||||
updated: number;
|
||||
skipped: number;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,8 @@ export interface RolePayload {
|
|||
|
||||
export interface CurrentUser {
|
||||
id: number;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
username: string;
|
||||
email: string;
|
||||
role: string;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue