diff --git a/frontend/src/app/(app)/dashboard/page.tsx b/frontend/src/app/(app)/dashboard/page.tsx new file mode 100644 index 0000000..e9498ca --- /dev/null +++ b/frontend/src/app/(app)/dashboard/page.tsx @@ -0,0 +1,69 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { api } from "@/lib/api"; +import type { DashboardData } from "@/lib/types"; +import { DeadlineTrafficLights } from "@/components/dashboard/DeadlineTrafficLights"; +import { CaseOverviewGrid } from "@/components/dashboard/CaseOverviewGrid"; +import { UpcomingTimeline } from "@/components/dashboard/UpcomingTimeline"; +import { AISummaryCard } from "@/components/dashboard/AISummaryCard"; +import { QuickActions } from "@/components/dashboard/QuickActions"; +import { Loader2 } from "lucide-react"; + +export default function DashboardPage() { + const { data, isLoading, error } = useQuery({ + queryKey: ["dashboard"], + queryFn: () => api.get("/dashboard"), + refetchInterval: 60_000, + }); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (error || !data) { + return ( +
+

+ Dashboard konnte nicht geladen werden. +

+
+ ); + } + + return ( +
+
+

Dashboard

+

+ Fristenübersicht und Kanzlei-Status +

+
+ + {/* Traffic Lights — the hero section */} + + + {/* Main content grid */} +
+ {/* Left column: Timeline (takes 2 cols) */} +
+ +
+ + {/* Right column: Case overview, AI summary, Quick actions */} +
+ + + +
+
+
+ ); +} diff --git a/frontend/src/app/(app)/page.tsx b/frontend/src/app/(app)/page.tsx index 6b5039d..c3a1c90 100644 --- a/frontend/src/app/(app)/page.tsx +++ b/frontend/src/app/(app)/page.tsx @@ -1,10 +1,5 @@ -export default function DashboardPage() { - return ( -
-

Dashboard

-

- Willkommen bei KanzlAI -

-
- ); +import { redirect } from "next/navigation"; + +export default function RootPage() { + redirect("/dashboard"); } diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 2291f25..15ececd 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -9,3 +9,18 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } + +@keyframes count-up { + 0% { + transform: translateY(8px); + opacity: 0; + } + 100% { + transform: translateY(0); + opacity: 1; + } +} + +.animate-count-up { + animation: count-up 0.3s ease-out; +} diff --git a/frontend/src/components/dashboard/AISummaryCard.tsx b/frontend/src/components/dashboard/AISummaryCard.tsx new file mode 100644 index 0000000..a1c1b1f --- /dev/null +++ b/frontend/src/components/dashboard/AISummaryCard.tsx @@ -0,0 +1,70 @@ +"use client"; + +import { Sparkles } from "lucide-react"; +import type { DashboardData } from "@/lib/types"; + +interface Props { + data: DashboardData; +} + +function generateSummary(data: DashboardData): string { + const parts: string[] = []; + const { deadline_summary: ds, case_summary: cs, upcoming_deadlines: ud } = data; + + // Deadline urgency + if (ds.overdue_count > 0) { + parts.push( + `${ds.overdue_count} Frist${ds.overdue_count > 1 ? "en" : ""} ${ds.overdue_count > 1 ? "sind" : "ist"} überfällig und ${ds.overdue_count > 1 ? "erfordern" : "erfordert"} sofortige Aufmerksamkeit.`, + ); + } + + if (ds.due_this_week > 0) { + parts.push( + `${ds.due_this_week} Frist${ds.due_this_week > 1 ? "en laufen" : " läuft"} diese Woche ab.`, + ); + } + + // Highlight most critical upcoming deadline + if (ud.length > 0) { + const next = ud[0]; + parts.push( + `Die nächste Frist ist "${next.title}" in Akte ${next.case_number}.`, + ); + } + + // Case activity + if (cs.new_this_month > 0) { + parts.push( + `${cs.new_this_month} neue Akte${cs.new_this_month > 1 ? "n" : ""} diesen Monat bei ${cs.active_count} aktiven Verfahren.`, + ); + } else { + parts.push(`${cs.active_count} aktive Verfahren.`); + } + + // All good + if (ds.overdue_count === 0 && ds.due_this_week === 0) { + parts.unshift("Alle Fristen sind im Zeitplan."); + } + + return parts.join(" "); +} + +export function AISummaryCard({ data }: Props) { + const summary = generateSummary(data); + + return ( +
+
+
+ +
+

+ KI-Zusammenfassung +

+
+

+ {summary} +

+
+ ); +} diff --git a/frontend/src/components/dashboard/CaseOverviewGrid.tsx b/frontend/src/components/dashboard/CaseOverviewGrid.tsx new file mode 100644 index 0000000..3d013cf --- /dev/null +++ b/frontend/src/components/dashboard/CaseOverviewGrid.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { FolderOpen, FolderPlus, Archive } from "lucide-react"; +import type { CaseSummary } from "@/lib/types"; + +interface Props { + data: CaseSummary; +} + +export function CaseOverviewGrid({ data }: Props) { + const items = [ + { + label: "Aktive Akten", + value: data.active_count, + icon: FolderOpen, + color: "text-blue-600", + bg: "bg-blue-50", + }, + { + label: "Neu (Monat)", + value: data.new_this_month, + icon: FolderPlus, + color: "text-violet-600", + bg: "bg-violet-50", + }, + { + label: "Abgeschlossen", + value: data.closed_count, + icon: Archive, + color: "text-neutral-500", + bg: "bg-neutral-50", + }, + ]; + + return ( +
+

Aktenübersicht

+
+ {items.map((item) => ( +
+
+
+ +
+ {item.label} +
+ + {item.value} + +
+ ))} +
+
+ ); +} diff --git a/frontend/src/components/dashboard/DeadlineTrafficLights.tsx b/frontend/src/components/dashboard/DeadlineTrafficLights.tsx new file mode 100644 index 0000000..82cd5cf --- /dev/null +++ b/frontend/src/components/dashboard/DeadlineTrafficLights.tsx @@ -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(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 ( + + {value} + + ); +} + +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 ( +
+ {cards.map((card) => ( + + ))} +
+ ); +} diff --git a/frontend/src/components/dashboard/QuickActions.tsx b/frontend/src/components/dashboard/QuickActions.tsx new file mode 100644 index 0000000..7ee0543 --- /dev/null +++ b/frontend/src/components/dashboard/QuickActions.tsx @@ -0,0 +1,53 @@ +"use client"; + +import Link from "next/link"; +import { FolderPlus, Clock, Sparkles, CalendarSync } from "lucide-react"; + +const actions = [ + { + label: "Neue Akte", + href: "/akten?new=1", + icon: FolderPlus, + color: "text-blue-600 bg-blue-50 hover:bg-blue-100", + }, + { + label: "Frist eintragen", + href: "/fristen?new=1", + icon: Clock, + color: "text-amber-600 bg-amber-50 hover:bg-amber-100", + }, + { + label: "AI Analyse", + href: "/ai", + icon: Sparkles, + color: "text-violet-600 bg-violet-50 hover:bg-violet-100", + }, + { + label: "CalDAV Sync", + href: "/einstellungen", + icon: CalendarSync, + color: "text-emerald-600 bg-emerald-50 hover:bg-emerald-100", + }, +]; + +export function QuickActions() { + return ( +
+

+ Schnellzugriff +

+
+ {actions.map((action) => ( + + + {action.label} + + ))} +
+
+ ); +} diff --git a/frontend/src/components/dashboard/UpcomingTimeline.tsx b/frontend/src/components/dashboard/UpcomingTimeline.tsx new file mode 100644 index 0000000..77c6dee --- /dev/null +++ b/frontend/src/components/dashboard/UpcomingTimeline.tsx @@ -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(); + 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 ( +
+

+ Nächste 7 Tage +

+ {empty ? ( +

+ Keine anstehenden Termine oder Fristen +

+ ) : ( +
+ {Array.from(grouped.entries()).map(([dateKey, dayItems]) => ( +
+

+ {formatDayLabel(dayItems[0].date)} +

+
+ {dayItems.map((item, i) => ( + + ))} +
+
+ ))} +
+ )} +
+ ); +} + +function TimelineEntry({ item }: { item: TimelineItem }) { + if (item.type === "deadline") { + const d = item.data; + return ( +
+
+ +
+
+

+ {d.title} +

+

+ {d.case_number} · {d.case_title} +

+
+ + Frist + +
+ ); + } + + const a = item.data; + return ( +
+
+ +
+
+

+ {a.title} +

+
+ {format(item.date, "HH:mm")} Uhr + {a.location && ( + <> + · + + + {a.location} + + + )} + {a.case_number && ( + <> + · + {a.case_number} + + )} +
+
+ + Termin + +
+ ); +} diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 218b06c..468c7f7 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -12,7 +12,7 @@ import { } from "lucide-react"; const navigation = [ - { name: "Dashboard", href: "/", icon: LayoutDashboard }, + { name: "Dashboard", href: "/dashboard", icon: LayoutDashboard }, { name: "Akten", href: "/akten", icon: FolderOpen }, { name: "Fristen", href: "/fristen", icon: Clock }, { name: "Termine", href: "/termine", icon: Calendar }, @@ -30,10 +30,7 @@ export function Sidebar() {