345 lines
12 KiB
TypeScript
345 lines
12 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useState } from "react";
|
|
import Link from "next/link";
|
|
import { useParams, useRouter } from "next/navigation";
|
|
import { ArrowLeft, Edit, Plus, Trash2 } from "lucide-react";
|
|
|
|
import ConfirmDialog from "@/components/common/ConfirmDialog";
|
|
import DetailSection from "@/components/common/DetailSection";
|
|
import { Button } from "@/components/ui/button";
|
|
import CustomerContactFormDialog from "@/components/customers/CustomerContactFormDialog";
|
|
import CustomerFormDialog from "@/components/customers/CustomerFormDialog";
|
|
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, CustomerContact, CustomerContactPayload, CustomerPayload } from "@/types/customer";
|
|
|
|
function getErrorMessage(error: unknown) {
|
|
if (typeof error === "object" && error !== null && "response" in error) {
|
|
const response = (error as { response?: { data?: { detail?: string } } }).response;
|
|
return response?.data?.detail ?? "Aktion konnte nicht abgeschlossen werden";
|
|
}
|
|
return "Aktion konnte nicht abgeschlossen werden";
|
|
}
|
|
|
|
function formatDateTime(value: string) {
|
|
return new Intl.DateTimeFormat("de-DE", {
|
|
dateStyle: "medium",
|
|
timeStyle: "short",
|
|
}).format(new Date(value));
|
|
}
|
|
|
|
export default function CustomerDetailPage() {
|
|
const params = useParams<{ id: string }>();
|
|
const router = useRouter();
|
|
const [customer, setCustomer] = useState<Customer | null>(null);
|
|
const [currentUser, setCurrentUser] = useState<CurrentUser | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState("");
|
|
const [pending, setPending] = useState(false);
|
|
const [formOpen, setFormOpen] = useState(false);
|
|
const [formError, setFormError] = useState("");
|
|
const [contactOpen, setContactOpen] = useState(false);
|
|
const [contactError, setContactError] = useState("");
|
|
const [selectedContact, setSelectedContact] = useState<CustomerContact | null>(null);
|
|
const [deleteContact, setDeleteContact] = useState<CustomerContact | null>(null);
|
|
const [deleteCustomerOpen, setDeleteCustomerOpen] = useState(false);
|
|
const [deleteError, setDeleteError] = useState("");
|
|
|
|
const loadCustomer = useCallback(async () => {
|
|
try {
|
|
const [customerResponse, meResponse] = await Promise.all([
|
|
api.get<Customer>(`/customers/${params.id}`),
|
|
api.get<CurrentUser>("/me"),
|
|
]);
|
|
setCustomer(customerResponse.data);
|
|
setCurrentUser(meResponse.data);
|
|
} catch (err) {
|
|
setError(getErrorMessage(err));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [params.id]);
|
|
|
|
useEffect(() => {
|
|
queueMicrotask(() => {
|
|
void loadCustomer();
|
|
});
|
|
}, [loadCustomer]);
|
|
|
|
async function saveCustomer(payload: CustomerPayload) {
|
|
if (!customer) {
|
|
return;
|
|
}
|
|
setPending(true);
|
|
setFormError("");
|
|
try {
|
|
const response = await api.put<Customer>(`/customers/${customer.id}`, payload);
|
|
setCustomer(response.data);
|
|
setFormOpen(false);
|
|
} catch (err) {
|
|
setFormError(getErrorMessage(err));
|
|
} finally {
|
|
setPending(false);
|
|
}
|
|
}
|
|
|
|
function openContactDialog(contact: CustomerContact | null) {
|
|
setSelectedContact(contact);
|
|
setContactError("");
|
|
setContactOpen(true);
|
|
}
|
|
|
|
async function saveContact(payload: CustomerContactPayload) {
|
|
if (!customer) {
|
|
return;
|
|
}
|
|
setPending(true);
|
|
setContactError("");
|
|
try {
|
|
if (selectedContact) {
|
|
await api.put<CustomerContact>(`/customers/${customer.id}/contacts/${selectedContact.id}`, payload);
|
|
} else {
|
|
await api.post<CustomerContact>(`/customers/${customer.id}/contacts`, payload);
|
|
}
|
|
const response = await api.get<Customer>(`/customers/${customer.id}`);
|
|
setCustomer(response.data);
|
|
setContactOpen(false);
|
|
} catch (err) {
|
|
setContactError(getErrorMessage(err));
|
|
} finally {
|
|
setPending(false);
|
|
}
|
|
}
|
|
|
|
async function confirmContactDelete() {
|
|
if (!customer || !deleteContact) {
|
|
return;
|
|
}
|
|
setPending(true);
|
|
setDeleteError("");
|
|
try {
|
|
await api.delete(`/customers/${customer.id}/contacts/${deleteContact.id}`);
|
|
const response = await api.get<Customer>(`/customers/${customer.id}`);
|
|
setCustomer(response.data);
|
|
setDeleteContact(null);
|
|
} catch (err) {
|
|
setDeleteError(getErrorMessage(err));
|
|
} finally {
|
|
setPending(false);
|
|
}
|
|
}
|
|
|
|
async function confirmCustomerDelete() {
|
|
if (!customer) {
|
|
return;
|
|
}
|
|
setPending(true);
|
|
setDeleteError("");
|
|
try {
|
|
await api.delete(`/customers/${customer.id}`);
|
|
router.push("/customers");
|
|
} catch (err) {
|
|
setDeleteError(getErrorMessage(err));
|
|
} finally {
|
|
setPending(false);
|
|
}
|
|
}
|
|
|
|
if (loading) {
|
|
return <div className="rounded-lg border bg-white p-8 text-slate-500">Kunde wird geladen...</div>;
|
|
}
|
|
|
|
if (error || !customer) {
|
|
return (
|
|
<div className="space-y-4">
|
|
<Link href="/customers" className="inline-flex items-center gap-2 text-sm text-slate-500 hover:text-slate-900">
|
|
<ArrowLeft size={16} />
|
|
Zurück zur Kundenliste
|
|
</Link>
|
|
<div className="rounded-lg border bg-white p-8 text-red-600">{error || "Kunde nicht gefunden"}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
|
<div>
|
|
<Link href="/customers" className="inline-flex items-center gap-2 text-sm text-slate-500 hover:text-slate-900">
|
|
<ArrowLeft size={16} />
|
|
Zurück zur Kundenliste
|
|
</Link>
|
|
<div className="mt-3 flex items-center gap-3">
|
|
<h1 className="text-3xl font-bold text-slate-950">{customer.company_name}</h1>
|
|
<CustomerStatusBadge status={customer.status} />
|
|
</div>
|
|
<p className="mt-1 text-sm text-slate-500">{customer.customer_number}</p>
|
|
</div>
|
|
|
|
<div className="flex gap-2">
|
|
{hasPermission(currentUser, "customers.update") && (
|
|
<Button type="button" onClick={() => setFormOpen(true)}>
|
|
<Edit size={16} />
|
|
Bearbeiten
|
|
</Button>
|
|
)}
|
|
{hasPermission(currentUser, "customers.delete") && (
|
|
<Button type="button" variant="destructive" onClick={() => setDeleteCustomerOpen(true)}>
|
|
<Trash2 size={16} />
|
|
Löschen
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<DetailSection title="Stammdaten">
|
|
<dl className="grid gap-5 md:grid-cols-2">
|
|
<Detail label="Kundennummer" value={customer.customer_number} />
|
|
<Detail label="Typ" value={customer.customer_type} />
|
|
<Detail label="Rechtlicher Name" value={customer.legal_name || "-"} />
|
|
<Detail label="Branche" value={customer.industry || "-"} />
|
|
<Detail label="E-Mail" value={customer.email || "-"} />
|
|
<Detail label="Telefon" value={customer.phone || "-"} />
|
|
<Detail label="Website" value={customer.website || "-"} />
|
|
<Detail label="USt-ID" value={customer.vat_id || "-"} />
|
|
</dl>
|
|
</DetailSection>
|
|
|
|
<DetailSection title="Adressen">
|
|
{customer.addresses.length === 0 ? (
|
|
<p className="text-sm text-slate-500">Noch keine Adressen vorhanden.</p>
|
|
) : (
|
|
<div className="grid gap-3 md:grid-cols-2">
|
|
{customer.addresses.map((address) => (
|
|
<div key={address.id} className="rounded-lg border p-4 text-sm">
|
|
<div className="mb-2 flex items-center justify-between">
|
|
<span className="font-medium capitalize">{address.type}</span>
|
|
{address.is_primary && <span className="rounded-full bg-slate-100 px-2 py-1 text-xs">Primär</span>}
|
|
</div>
|
|
<p>{address.street || "-"}</p>
|
|
<p>{address.postal_code} {address.city}</p>
|
|
<p>{address.state}</p>
|
|
<p>{address.country}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</DetailSection>
|
|
|
|
<DetailSection
|
|
title="Ansprechpartner"
|
|
actions={hasPermission(currentUser, "customers.update") && (
|
|
<Button type="button" size="sm" onClick={() => openContactDialog(null)}>
|
|
<Plus size={16} />
|
|
Ansprechpartner
|
|
</Button>
|
|
)}
|
|
>
|
|
{customer.contacts.length === 0 ? (
|
|
<p className="text-sm text-slate-500">Noch keine Ansprechpartner vorhanden.</p>
|
|
) : (
|
|
<div className="grid gap-3 md:grid-cols-2">
|
|
{customer.contacts.map((contact) => (
|
|
<div key={contact.id} className="rounded-lg border p-4 text-sm">
|
|
<div className="mb-2 flex items-center justify-between gap-3">
|
|
<div>
|
|
<p className="font-medium text-slate-950">{contact.first_name} {contact.last_name}</p>
|
|
<p className="text-slate-500">{contact.position || "-"}</p>
|
|
</div>
|
|
{contact.is_primary && <span className="rounded-full bg-slate-100 px-2 py-1 text-xs">Primär</span>}
|
|
</div>
|
|
<p>{contact.email || "-"}</p>
|
|
<p>{contact.phone || contact.mobile || "-"}</p>
|
|
{hasPermission(currentUser, "customers.update") && (
|
|
<div className="mt-3 flex gap-2">
|
|
<Button type="button" variant="outline" size="sm" onClick={() => openContactDialog(contact)}>
|
|
Bearbeiten
|
|
</Button>
|
|
<Button type="button" variant="destructive" size="sm" onClick={() => setDeleteContact(contact)}>
|
|
Löschen
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</DetailSection>
|
|
|
|
<DetailSection title="Notizen">
|
|
<p className="whitespace-pre-wrap text-sm text-slate-700">{customer.notes || "Noch keine Notizen vorhanden."}</p>
|
|
</DetailSection>
|
|
|
|
<DetailSection title="Metadaten">
|
|
<dl className="grid gap-5 md:grid-cols-2">
|
|
<Detail label="Erstellt" value={formatDateTime(customer.created_at)} />
|
|
<Detail label="Aktualisiert" value={formatDateTime(customer.updated_at)} />
|
|
</dl>
|
|
</DetailSection>
|
|
|
|
<CustomerFormDialog
|
|
open={formOpen}
|
|
customer={customer}
|
|
pending={pending}
|
|
serverError={formError}
|
|
onOpenChange={setFormOpen}
|
|
onSubmit={saveCustomer}
|
|
/>
|
|
|
|
<CustomerContactFormDialog
|
|
open={contactOpen}
|
|
contact={selectedContact}
|
|
pending={pending}
|
|
serverError={contactError}
|
|
onOpenChange={setContactOpen}
|
|
onSubmit={saveContact}
|
|
/>
|
|
|
|
<ConfirmDialog
|
|
open={Boolean(deleteContact)}
|
|
title="Ansprechpartner löschen"
|
|
description="Möchten Sie den Ansprechpartner wirklich löschen?"
|
|
pending={pending}
|
|
onOpenChange={(open) => {
|
|
if (!open) {
|
|
setDeleteContact(null);
|
|
setDeleteError("");
|
|
}
|
|
}}
|
|
onConfirm={confirmContactDelete}
|
|
>
|
|
{deleteContact && <p className="text-sm font-medium">{deleteContact.first_name} {deleteContact.last_name}</p>}
|
|
{deleteError && <p className="mt-2 text-sm text-red-600">{deleteError}</p>}
|
|
</ConfirmDialog>
|
|
|
|
<ConfirmDialog
|
|
open={deleteCustomerOpen}
|
|
title="Kunde löschen"
|
|
description="Möchten Sie den Kunden wirklich löschen?"
|
|
pending={pending}
|
|
onOpenChange={(open) => {
|
|
setDeleteCustomerOpen(open);
|
|
if (!open) {
|
|
setDeleteError("");
|
|
}
|
|
}}
|
|
onConfirm={confirmCustomerDelete}
|
|
>
|
|
<p className="text-sm font-medium">{customer.customer_number} · {customer.company_name}</p>
|
|
{deleteError && <p className="mt-2 text-sm text-red-600">{deleteError}</p>}
|
|
</ConfirmDialog>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Detail({ label, value }: { label: string; value: string }) {
|
|
return (
|
|
<div>
|
|
<dt className="text-sm text-slate-500">{label}</dt>
|
|
<dd className="mt-1 font-medium text-slate-950">{value}</dd>
|
|
</div>
|
|
);
|
|
}
|