Files
KanzlAI-mGMT/frontend/src/components/dashboard/UpcomingTimeline.tsx
m 45188ff5cb feat: add frontend dashboard with traffic lights, timeline, and AI summary (Phase 2L)
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.
2026-03-25 13:49:24 +01:00

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>
);
}