181 lines
6.3 KiB
TypeScript
181 lines
6.3 KiB
TypeScript
"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 { ActivityFeedItem, ApiSuccess } from "@/types/audit";
|
|
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() {
|
|
const [summary, setSummary] = useState<DashboardSummary | null>(null);
|
|
const [activities, setActivities] = useState<ActivityFeedItem[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState("");
|
|
const [activityError, setActivityError] = useState("");
|
|
|
|
const loadSummary = useCallback(async () => {
|
|
try {
|
|
const [summaryResponse, activityResponse] = await Promise.all([
|
|
api.get<DashboardSummary>("/dashboard/summary"),
|
|
api.get<ApiSuccess<ActivityFeedItem[]>>("/activity-feed?limit=10"),
|
|
]);
|
|
setSummary(summaryResponse.data);
|
|
setActivities(activityResponse.data.data);
|
|
} catch (err) {
|
|
setError(getErrorMessage(err));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
const loadActivities = useCallback(async () => {
|
|
setActivityError("");
|
|
try {
|
|
const response = await api.get<ApiSuccess<ActivityFeedItem[]>>("/activity-feed?limit=10");
|
|
setActivities(response.data.data);
|
|
} catch (err) {
|
|
setActivityError(getErrorMessage(err));
|
|
}
|
|
}, []);
|
|
|
|
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.repairs, ...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>
|
|
) : (
|
|
<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">
|
|
<ActivityPanel
|
|
widget={summary.activities}
|
|
activities={activities}
|
|
error={activityError}
|
|
onReload={() => void loadActivities()}
|
|
/>
|
|
<EmptyPanel widget={summary.tasks} />
|
|
<EmptyPanel widget={summary.tickets} />
|
|
<EmptyPanel widget={summary.projects} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ActivityPanel({
|
|
widget,
|
|
activities,
|
|
error,
|
|
onReload,
|
|
}: {
|
|
widget: EmptyWidget;
|
|
activities: ActivityFeedItem[];
|
|
error: string;
|
|
onReload: () => void;
|
|
}) {
|
|
return (
|
|
<div className="rounded-lg border bg-white p-5 md:col-span-2 xl:col-span-4">
|
|
<div className="mb-4 flex items-center justify-between">
|
|
<h2 className="font-semibold text-slate-950">{widget.title}</h2>
|
|
<button type="button" className="text-sm font-medium text-slate-600 hover:text-slate-950" onClick={onReload}>
|
|
Aktualisieren
|
|
</button>
|
|
</div>
|
|
|
|
{error ? (
|
|
<p className="text-sm text-red-600">{error}</p>
|
|
) : activities.length === 0 ? (
|
|
<p className="text-sm text-slate-500">{widget.message}</p>
|
|
) : (
|
|
<div className="divide-y">
|
|
{activities.map((activity) => (
|
|
<div key={activity.id} className="py-3">
|
|
<div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
|
|
<p className="font-medium text-slate-950">{activity.title}</p>
|
|
<time className="text-xs text-slate-500" dateTime={activity.created_at}>
|
|
{new Intl.DateTimeFormat("de-DE", {
|
|
dateStyle: "short",
|
|
timeStyle: "short",
|
|
}).format(new Date(activity.created_at))}
|
|
</time>
|
|
</div>
|
|
<p className="mt-1 text-sm text-slate-500">
|
|
{activity.description} · {activity.actor || "System"}
|
|
</p>
|
|
</div>
|
|
))}
|
|
</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>
|
|
);
|
|
}
|