Dashboard page at /dashboard with 5 components: - DeadlineTrafficLights: RED/AMBER/GREEN cards with animated counts and pulse for overdue - CaseOverviewGrid: active/new/closed case counts - UpcomingTimeline: merged deadlines + appointments for next 7 days, grouped by day - AISummaryCard: natural language summary generated from dashboard data - QuickActions: shortcuts to create cases, deadlines, AI analysis, CalDAV sync 3-column responsive grid layout. Root / redirects to /dashboard. Fetches from GET /api/dashboard with 60s auto-refresh via react-query.
135 lines
4.2 KiB
TypeScript
135 lines
4.2 KiB
TypeScript
"use client";
|
|
|
|
import { format, parseISO, isToday, isTomorrow } from "date-fns";
|
|
import { de } from "date-fns/locale";
|
|
import { Clock, Calendar, MapPin } from "lucide-react";
|
|
import type { UpcomingDeadline, UpcomingAppointment } from "@/lib/types";
|
|
|
|
interface Props {
|
|
deadlines: UpcomingDeadline[];
|
|
appointments: UpcomingAppointment[];
|
|
}
|
|
|
|
type TimelineItem =
|
|
| { type: "deadline"; date: Date; data: UpcomingDeadline }
|
|
| { type: "appointment"; date: Date; data: UpcomingAppointment };
|
|
|
|
function formatDayLabel(date: Date): string {
|
|
if (isToday(date)) return "Heute";
|
|
if (isTomorrow(date)) return "Morgen";
|
|
return format(date, "EEEE, d. MMM", { locale: de });
|
|
}
|
|
|
|
export function UpcomingTimeline({ deadlines, appointments }: Props) {
|
|
const items: TimelineItem[] = [
|
|
...deadlines.map((d) => ({
|
|
type: "deadline" as const,
|
|
date: parseISO(d.due_date),
|
|
data: d,
|
|
})),
|
|
...appointments.map((a) => ({
|
|
type: "appointment" as const,
|
|
date: parseISO(a.start_at),
|
|
data: a,
|
|
})),
|
|
].sort((a, b) => a.date.getTime() - b.date.getTime());
|
|
|
|
// Group by day
|
|
const grouped = new Map<string, TimelineItem[]>();
|
|
for (const item of items) {
|
|
const key = format(item.date, "yyyy-MM-dd");
|
|
const group = grouped.get(key) ?? [];
|
|
group.push(item);
|
|
grouped.set(key, group);
|
|
}
|
|
|
|
const empty = items.length === 0;
|
|
|
|
return (
|
|
<div className="rounded-xl border border-neutral-200 bg-white p-5">
|
|
<h2 className="text-sm font-semibold text-neutral-900">
|
|
Nächste 7 Tage
|
|
</h2>
|
|
{empty ? (
|
|
<p className="mt-6 text-center text-sm text-neutral-400">
|
|
Keine anstehenden Termine oder Fristen
|
|
</p>
|
|
) : (
|
|
<div className="mt-4 space-y-5">
|
|
{Array.from(grouped.entries()).map(([dateKey, dayItems]) => (
|
|
<div key={dateKey}>
|
|
<p className="text-xs font-medium uppercase tracking-wider text-neutral-400">
|
|
{formatDayLabel(dayItems[0].date)}
|
|
</p>
|
|
<div className="mt-2 space-y-2">
|
|
{dayItems.map((item, i) => (
|
|
<TimelineEntry key={`${item.type}-${i}`} item={item} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TimelineEntry({ item }: { item: TimelineItem }) {
|
|
if (item.type === "deadline") {
|
|
const d = item.data;
|
|
return (
|
|
<div className="flex items-start gap-3 rounded-lg border border-neutral-100 bg-neutral-50/50 px-3 py-2.5">
|
|
<div className="mt-0.5 rounded-md bg-amber-50 p-1">
|
|
<Clock className="h-3.5 w-3.5 text-amber-500" />
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="truncate text-sm font-medium text-neutral-900">
|
|
{d.title}
|
|
</p>
|
|
<p className="mt-0.5 truncate text-xs text-neutral-500">
|
|
{d.case_number} · {d.case_title}
|
|
</p>
|
|
</div>
|
|
<span className="shrink-0 text-xs font-medium text-amber-600">
|
|
Frist
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const a = item.data;
|
|
return (
|
|
<div className="flex items-start gap-3 rounded-lg border border-neutral-100 bg-neutral-50/50 px-3 py-2.5">
|
|
<div className="mt-0.5 rounded-md bg-blue-50 p-1">
|
|
<Calendar className="h-3.5 w-3.5 text-blue-500" />
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="truncate text-sm font-medium text-neutral-900">
|
|
{a.title}
|
|
</p>
|
|
<div className="mt-0.5 flex items-center gap-2 text-xs text-neutral-500">
|
|
<span>{format(item.date, "HH:mm")} Uhr</span>
|
|
{a.location && (
|
|
<>
|
|
<span className="text-neutral-300">·</span>
|
|
<span className="flex items-center gap-0.5 truncate">
|
|
<MapPin className="h-3 w-3" />
|
|
{a.location}
|
|
</span>
|
|
</>
|
|
)}
|
|
{a.case_number && (
|
|
<>
|
|
<span className="text-neutral-300">·</span>
|
|
<span>{a.case_number}</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<span className="shrink-0 text-xs font-medium text-blue-600">
|
|
Termin
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|