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:
m
2026-03-25 13:49:24 +01:00
parent 0fac764211
commit 45188ff5cb
10 changed files with 552 additions and 14 deletions

View File

@@ -0,0 +1,105 @@
"use client";
import { useEffect, useRef } from "react";
import { AlertTriangle, Clock, CheckCircle } from "lucide-react";
import type { DeadlineSummary } from "@/lib/types";
function AnimatedCount({ value }: { value: number }) {
const ref = useRef<HTMLSpanElement>(null);
const prevValue = useRef(value);
useEffect(() => {
const el = ref.current;
if (!el || prevValue.current === value) return;
el.classList.remove("animate-count-up");
void el.offsetWidth;
el.classList.add("animate-count-up");
prevValue.current = value;
}, [value]);
return (
<span ref={ref} className="inline-block tabular-nums">
{value}
</span>
);
}
interface Props {
data: DeadlineSummary;
onFilter?: (filter: "overdue" | "this_week" | "ok") => void;
}
export function DeadlineTrafficLights({ data, onFilter }: Props) {
const cards = [
{
key: "overdue" as const,
label: "Überfällig",
count: data.overdue_count,
icon: AlertTriangle,
bg: "bg-red-50",
border: "border-red-200",
iconColor: "text-red-500",
countColor: "text-red-700",
labelColor: "text-red-600",
ring: data.overdue_count > 0 ? "ring-2 ring-red-300 ring-offset-1" : "",
pulse: data.overdue_count > 0,
},
{
key: "this_week" as const,
label: "Diese Woche",
count: data.due_this_week,
icon: Clock,
bg: "bg-amber-50",
border: "border-amber-200",
iconColor: "text-amber-500",
countColor: "text-amber-700",
labelColor: "text-amber-600",
ring: "",
pulse: false,
},
{
key: "ok" as const,
label: "Im Zeitplan",
count: data.ok_count + data.due_next_week,
icon: CheckCircle,
bg: "bg-emerald-50",
border: "border-emerald-200",
iconColor: "text-emerald-500",
countColor: "text-emerald-700",
labelColor: "text-emerald-600",
ring: "",
pulse: false,
},
];
return (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
{cards.map((card) => (
<button
key={card.key}
onClick={() => onFilter?.(card.key)}
className={`group relative overflow-hidden rounded-xl border ${card.border} ${card.bg} ${card.ring} p-6 text-left transition-all hover:shadow-md active:scale-[0.98]`}
>
{card.pulse && (
<span className="absolute right-4 top-4 flex h-3 w-3">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-red-400 opacity-75" />
<span className="relative inline-flex h-3 w-3 rounded-full bg-red-500" />
</span>
)}
<div className="flex items-center gap-3">
<div className={`rounded-lg p-2 ${card.bg}`}>
<card.icon className={`h-5 w-5 ${card.iconColor}`} />
</div>
<span className={`text-sm font-medium ${card.labelColor}`}>
{card.label}
</span>
</div>
<div className={`mt-4 text-4xl font-bold tracking-tight ${card.countColor}`}>
<AnimatedCount value={card.count} />
</div>
</button>
))}
</div>
);
}