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.
This commit is contained in:
134
frontend/src/components/dashboard/UpcomingTimeline.tsx
Normal file
134
frontend/src/components/dashboard/UpcomingTimeline.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user