feat(customers): add dashboard and customer management
This commit is contained in:
parent
694b7bd09a
commit
92cb8d1286
28 changed files with 2521 additions and 13 deletions
|
|
@ -0,0 +1,36 @@
|
|||
import { NextRequest } from "next/server";
|
||||
|
||||
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||
|
||||
type Params = {
|
||||
params: Promise<{
|
||||
id: string;
|
||||
contactId: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
async function proxyContactRequest(request: NextRequest, { params }: Params) {
|
||||
const { id, contactId } = await params;
|
||||
return proxyHermesRequest(request, `/customers/${id}/contacts/${contactId}`);
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest, context: Params) {
|
||||
const originError = assertSameOrigin(request);
|
||||
|
||||
if (originError) {
|
||||
return originError;
|
||||
}
|
||||
|
||||
return proxyContactRequest(request, context);
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest, context: Params) {
|
||||
const originError = assertSameOrigin(request);
|
||||
|
||||
if (originError) {
|
||||
return originError;
|
||||
}
|
||||
|
||||
return proxyContactRequest(request, context);
|
||||
}
|
||||
29
frontend/athena/app/api/customers/[id]/contacts/route.ts
Normal file
29
frontend/athena/app/api/customers/[id]/contacts/route.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { NextRequest } from "next/server";
|
||||
|
||||
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||
|
||||
type Params = {
|
||||
params: Promise<{
|
||||
id: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
async function proxyContactsRequest(request: NextRequest, { params }: Params) {
|
||||
const { id } = await params;
|
||||
return proxyHermesRequest(request, `/customers/${id}/contacts`);
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest, context: Params) {
|
||||
return proxyContactsRequest(request, context);
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest, context: Params) {
|
||||
const originError = assertSameOrigin(request);
|
||||
|
||||
if (originError) {
|
||||
return originError;
|
||||
}
|
||||
|
||||
return proxyContactsRequest(request, context);
|
||||
}
|
||||
39
frontend/athena/app/api/customers/[id]/route.ts
Normal file
39
frontend/athena/app/api/customers/[id]/route.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { NextRequest } from "next/server";
|
||||
|
||||
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||
|
||||
type Params = {
|
||||
params: Promise<{
|
||||
id: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
async function proxyCustomerRequest(request: NextRequest, { params }: Params) {
|
||||
const { id } = await params;
|
||||
return proxyHermesRequest(request, `/customers/${id}`);
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest, context: Params) {
|
||||
return proxyCustomerRequest(request, context);
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest, context: Params) {
|
||||
const originError = assertSameOrigin(request);
|
||||
|
||||
if (originError) {
|
||||
return originError;
|
||||
}
|
||||
|
||||
return proxyCustomerRequest(request, context);
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest, context: Params) {
|
||||
const originError = assertSameOrigin(request);
|
||||
|
||||
if (originError) {
|
||||
return originError;
|
||||
}
|
||||
|
||||
return proxyCustomerRequest(request, context);
|
||||
}
|
||||
18
frontend/athena/app/api/customers/route.ts
Normal file
18
frontend/athena/app/api/customers/route.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { NextRequest } from "next/server";
|
||||
|
||||
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
return proxyHermesRequest(request, `/customers${request.nextUrl.search}`);
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const originError = assertSameOrigin(request);
|
||||
|
||||
if (originError) {
|
||||
return originError;
|
||||
}
|
||||
|
||||
return proxyHermesRequest(request, "/customers");
|
||||
}
|
||||
7
frontend/athena/app/api/dashboard/summary/route.ts
Normal file
7
frontend/athena/app/api/dashboard/summary/route.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { NextRequest } from "next/server";
|
||||
|
||||
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
return proxyHermesRequest(request, "/dashboard/summary");
|
||||
}
|
||||
345
frontend/athena/app/customers/[id]/page.tsx
Normal file
345
frontend/athena/app/customers/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,345 @@
|
|||
"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>
|
||||
);
|
||||
}
|
||||
309
frontend/athena/app/customers/page.tsx
Normal file
309
frontend/athena/app/customers/page.tsx
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Edit, Eye, Plus, Trash2 } from "lucide-react";
|
||||
|
||||
import ConfirmDialog from "@/components/common/ConfirmDialog";
|
||||
import DataTable, { type DataTableColumn } from "@/components/common/DataTable";
|
||||
import SearchInput from "@/components/common/SearchInput";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
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, CustomerPayload, CustomerStatus, CustomerType } from "@/types/customer";
|
||||
|
||||
const pageSize = 10;
|
||||
|
||||
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 formatDate(value: string) {
|
||||
return new Intl.DateTimeFormat("de-DE", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
function getPrimaryCity(customer: Customer) {
|
||||
return customer.addresses.find((address) => address.is_primary)?.city
|
||||
|| customer.addresses[0]?.city
|
||||
|| "-";
|
||||
}
|
||||
|
||||
function getSortableValue(customer: Customer, key: string) {
|
||||
if (key === "city") {
|
||||
return getPrimaryCity(customer).toLowerCase();
|
||||
}
|
||||
const value = customer[key as keyof Customer];
|
||||
return typeof value === "string" ? value.toLowerCase() : value;
|
||||
}
|
||||
|
||||
export default function CustomersPage() {
|
||||
const [customers, setCustomers] = useState<Customer[]>([]);
|
||||
const [currentUser, setCurrentUser] = useState<CurrentUser | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [formError, setFormError] = useState("");
|
||||
const [deleteError, setDeleteError] = useState("");
|
||||
const [pending, setPending] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<CustomerStatus | "all">("all");
|
||||
const [typeFilter, setTypeFilter] = useState<CustomerType | "all">("all");
|
||||
const [sortKey, setSortKey] = useState("created_at");
|
||||
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("desc");
|
||||
const [page, setPage] = useState(1);
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [selectedCustomer, setSelectedCustomer] = useState<Customer | null>(null);
|
||||
const [deleteCustomer, setDeleteCustomer] = useState<Customer | null>(null);
|
||||
|
||||
const loadCustomers = useCallback(async () => {
|
||||
try {
|
||||
const [customersResponse, meResponse] = await Promise.all([
|
||||
api.get<Customer[]>("/customers"),
|
||||
api.get<CurrentUser>("/me"),
|
||||
]);
|
||||
setCustomers(customersResponse.data);
|
||||
setCurrentUser(meResponse.data);
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
void loadCustomers();
|
||||
});
|
||||
}, [loadCustomers]);
|
||||
|
||||
const filteredCustomers = useMemo(() => {
|
||||
const term = search.trim().toLowerCase();
|
||||
|
||||
return customers
|
||||
.filter((customer) => {
|
||||
if (!term) {
|
||||
return true;
|
||||
}
|
||||
return [
|
||||
customer.customer_number,
|
||||
customer.company_name,
|
||||
customer.legal_name,
|
||||
customer.industry,
|
||||
customer.email,
|
||||
customer.phone,
|
||||
getPrimaryCity(customer),
|
||||
].some((value) => value.toLowerCase().includes(term));
|
||||
})
|
||||
.filter((customer) => statusFilter === "all" || customer.status === statusFilter)
|
||||
.filter((customer) => typeFilter === "all" || customer.customer_type === typeFilter)
|
||||
.sort((first, second) => {
|
||||
const firstValue = getSortableValue(first, sortKey);
|
||||
const secondValue = getSortableValue(second, sortKey);
|
||||
if (firstValue === secondValue) {
|
||||
return 0;
|
||||
}
|
||||
const result = firstValue > secondValue ? 1 : -1;
|
||||
return sortDirection === "asc" ? result : -result;
|
||||
});
|
||||
}, [customers, search, sortDirection, sortKey, statusFilter, typeFilter]);
|
||||
|
||||
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 columns: DataTableColumn<Customer>[] = [
|
||||
{ key: "customer_number", label: "Kundennummer", sortable: true, render: (customer) => customer.customer_number },
|
||||
{ key: "company_name", label: "Firmenname", sortable: true, render: (customer) => <span className="font-medium text-slate-950">{customer.company_name}</span> },
|
||||
{ key: "customer_type", label: "Typ", sortable: true, render: (customer) => customer.customer_type },
|
||||
{ key: "status", label: "Status", sortable: true, render: (customer) => <CustomerStatusBadge status={customer.status} /> },
|
||||
{ key: "industry", label: "Branche", sortable: true, render: (customer) => customer.industry || "-" },
|
||||
{ key: "email", label: "E-Mail", sortable: true, render: (customer) => customer.email || "-" },
|
||||
{ key: "phone", label: "Telefon", sortable: true, render: (customer) => customer.phone || "-" },
|
||||
{ key: "city", label: "Ort", sortable: true, render: (customer) => getPrimaryCity(customer) },
|
||||
{ key: "created_at", label: "Erstellt", sortable: true, render: (customer) => formatDate(customer.created_at) },
|
||||
{
|
||||
key: "actions",
|
||||
label: "Aktionen",
|
||||
className: "px-4 py-3 text-right",
|
||||
render: (customer) => (
|
||||
<div className="flex justify-end gap-2">
|
||||
<Link href={`/customers/${customer.id}`} className={buttonVariants({ variant: "ghost", size: "icon-sm" })} title="Details">
|
||||
<Eye size={16} />
|
||||
</Link>
|
||||
{hasPermission(currentUser, "customers.update") && (
|
||||
<Button type="button" variant="ghost" size="icon-sm" title="Bearbeiten" onClick={() => openEditDialog(customer)}>
|
||||
<Edit size={16} />
|
||||
</Button>
|
||||
)}
|
||||
{hasPermission(currentUser, "customers.delete") && (
|
||||
<Button type="button" variant="destructive" size="icon-sm" title="Löschen" onClick={() => setDeleteCustomer(customer)}>
|
||||
<Trash2 size={16} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
function handleSort(key: string) {
|
||||
if (sortKey === key) {
|
||||
setSortDirection((current) => (current === "asc" ? "desc" : "asc"));
|
||||
return;
|
||||
}
|
||||
setSortKey(key);
|
||||
setSortDirection("asc");
|
||||
}
|
||||
|
||||
function openCreateDialog() {
|
||||
setSelectedCustomer(null);
|
||||
setFormError("");
|
||||
setFormOpen(true);
|
||||
}
|
||||
|
||||
function openEditDialog(customer: Customer) {
|
||||
setSelectedCustomer(customer);
|
||||
setFormError("");
|
||||
setFormOpen(true);
|
||||
}
|
||||
|
||||
async function saveCustomer(payload: CustomerPayload) {
|
||||
setPending(true);
|
||||
setFormError("");
|
||||
try {
|
||||
if (selectedCustomer) {
|
||||
const response = await api.put<Customer>(`/customers/${selectedCustomer.id}`, payload);
|
||||
setCustomers((current) => current.map((customer) => customer.id === selectedCustomer.id ? response.data : customer));
|
||||
} else {
|
||||
const response = await api.post<Customer>("/customers", payload);
|
||||
setCustomers((current) => [response.data, ...current]);
|
||||
}
|
||||
setFormOpen(false);
|
||||
} catch (err) {
|
||||
setFormError(getErrorMessage(err));
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!deleteCustomer) {
|
||||
return;
|
||||
}
|
||||
setPending(true);
|
||||
setDeleteError("");
|
||||
try {
|
||||
await api.delete(`/customers/${deleteCustomer.id}`);
|
||||
setCustomers((current) => current.filter((customer) => customer.id !== deleteCustomer.id));
|
||||
setDeleteCustomer(null);
|
||||
} catch (err) {
|
||||
setDeleteError(getErrorMessage(err));
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<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>
|
||||
|
||||
<div className="flex flex-col gap-3 rounded-lg border bg-white p-4 lg:flex-row lg:items-center">
|
||||
<SearchInput value={search} onChange={(value) => { setSearch(value); setPage(1); }} placeholder="Kunden suchen" />
|
||||
<select value={statusFilter} onChange={(event) => { setStatusFilter(event.target.value as CustomerStatus | "all"); setPage(1); }} className="h-8 rounded-lg border border-input bg-transparent px-2.5 text-sm">
|
||||
<option value="all">Alle Status</option>
|
||||
<option value="lead">Lead</option>
|
||||
<option value="active">Aktiv</option>
|
||||
<option value="inactive">Inaktiv</option>
|
||||
<option value="blocked">Gesperrt</option>
|
||||
<option value="archived">Archiviert</option>
|
||||
</select>
|
||||
<select value={typeFilter} onChange={(event) => { setTypeFilter(event.target.value as CustomerType | "all"); setPage(1); }} className="h-8 rounded-lg border border-input bg-transparent px-2.5 text-sm">
|
||||
<option value="all">Alle Typen</option>
|
||||
<option value="company">Unternehmen</option>
|
||||
<option value="private">Privat</option>
|
||||
<option value="public_sector">Öffentlicher Sektor</option>
|
||||
<option value="partner">Partner</option>
|
||||
<option value="supplier">Lieferant</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={pageCustomers}
|
||||
rowKey={(customer) => customer.id}
|
||||
sortKey={sortKey}
|
||||
sortDirection={sortDirection}
|
||||
loading={loading}
|
||||
error={error}
|
||||
emptyTitle="Keine Kunden gefunden"
|
||||
emptyDescription="Passe Suche oder Filter an oder erstelle einen neuen Kunden."
|
||||
onSort={handleSort}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between text-sm text-slate-600">
|
||||
<span>Seite {currentPage} von {pageCount}</span>
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" variant="outline" disabled={currentPage === 1} onClick={() => setPage((current) => Math.max(1, current - 1))}>Zurück</Button>
|
||||
<Button type="button" variant="outline" disabled={currentPage === pageCount} onClick={() => setPage((current) => Math.min(pageCount, current + 1))}>Weiter</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CustomerFormDialog
|
||||
open={formOpen}
|
||||
customer={selectedCustomer}
|
||||
pending={pending}
|
||||
serverError={formError}
|
||||
onOpenChange={setFormOpen}
|
||||
onSubmit={saveCustomer}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={Boolean(deleteCustomer)}
|
||||
title="Kunde löschen"
|
||||
description="Möchten Sie den Kunden wirklich löschen?"
|
||||
pending={pending}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setDeleteCustomer(null);
|
||||
setDeleteError("");
|
||||
}
|
||||
}}
|
||||
onConfirm={confirmDelete}
|
||||
>
|
||||
{deleteCustomer && (
|
||||
<dl className="grid gap-2 text-sm">
|
||||
<div className="flex justify-between gap-4">
|
||||
<dt className="text-slate-500">Kundennummer</dt>
|
||||
<dd className="font-medium">{deleteCustomer.customer_number}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4">
|
||||
<dt className="text-slate-500">Firma</dt>
|
||||
<dd className="font-medium">{deleteCustomer.company_name}</dd>
|
||||
</div>
|
||||
{deleteError && <p className="text-red-600">{deleteError}</p>}
|
||||
</dl>
|
||||
)}
|
||||
</ConfirmDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,15 +1,111 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
import SummaryCard from "@/components/common/SummaryCard";
|
||||
import { api } from "@/lib/api";
|
||||
import type { DashboardSummary, EmptyWidget } from "@/types/dashboard";
|
||||
|
||||
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 ?? "Dashboard konnte nicht geladen werden";
|
||||
}
|
||||
return "Dashboard konnte nicht geladen werden";
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
return (
|
||||
<main className="min-h-screen bg-slate-100">
|
||||
<div className="mx-auto max-w-7xl p-8">
|
||||
<h1 className="text-4xl font-bold">
|
||||
Olympus Dashboard
|
||||
</h1>
|
||||
|
||||
<p className="mt-4 text-slate-600">
|
||||
Willkommen bei Funktechnik Schubert.
|
||||
</p>
|
||||
const [summary, setSummary] = useState<DashboardSummary | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const loadSummary = useCallback(async () => {
|
||||
try {
|
||||
const response = await api.get<DashboardSummary>("/dashboard/summary");
|
||||
setSummary(response.data);
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
void loadSummary();
|
||||
});
|
||||
}, [loadSummary]);
|
||||
|
||||
if (loading) {
|
||||
return <div className="rounded-lg border bg-white p-8 text-slate-500">Dashboard wird geladen...</div>;
|
||||
}
|
||||
|
||||
if (error || !summary) {
|
||||
return <div className="rounded-lg border bg-white p-8 text-red-600">{error || "Keine Dashboarddaten verfügbar"}</div>;
|
||||
}
|
||||
|
||||
const cards = [...summary.customers, ...summary.users, ...summary.roles];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-slate-950">Dashboard</h1>
|
||||
<p className="mt-1 text-sm text-slate-500">Operative Übersicht für Olympus CRM</p>
|
||||
</div>
|
||||
|
||||
{cards.length > 0 ? (
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
{cards.map((card) => (
|
||||
<SummaryCard key={card.label} label={card.label} value={card.value} />
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
) : (
|
||||
<EmptyPanel widget={{ title: "Keine Kennzahlen", message: "Für deine Berechtigungen sind aktuell keine Kennzahlen verfügbar." }} />
|
||||
)}
|
||||
|
||||
<section className="rounded-lg border bg-white p-6">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-slate-950">Letzte Kunden</h2>
|
||||
{summary.latest_customers.length > 0 && (
|
||||
<Link href="/customers" className="text-sm font-medium text-slate-600 hover:text-slate-950">
|
||||
Alle Kunden
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{summary.latest_customers.length === 0 ? (
|
||||
<p className="text-sm text-slate-500">Noch keine Kundendaten vorhanden.</p>
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
{summary.latest_customers.map((customer) => (
|
||||
<Link key={customer.id} href={`/customers/${customer.id}`} className="flex items-center justify-between py-3 hover:bg-slate-50">
|
||||
<div>
|
||||
<p className="font-medium text-slate-950">{customer.company_name}</p>
|
||||
<p className="text-sm text-slate-500">{customer.customer_number}</p>
|
||||
</div>
|
||||
<span className="text-sm text-slate-500">{customer.status}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<EmptyPanel widget={summary.activities} />
|
||||
<EmptyPanel widget={summary.tasks} />
|
||||
<EmptyPanel widget={summary.tickets} />
|
||||
<EmptyPanel widget={summary.projects} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyPanel({ widget }: { widget: EmptyWidget }) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-white p-5">
|
||||
<h2 className="font-semibold text-slate-950">{widget.title}</h2>
|
||||
<p className="mt-2 text-sm text-slate-500">{widget.message}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue