From 749273fba714e113ff0e8b88bdc83a8b87f705e6 Mon Sep 17 00:00:00 2001 From: m Date: Wed, 25 Mar 2026 14:00:56 +0100 Subject: [PATCH] feat: add appointment calendar frontend (Phase 1H) - /termine page with list/calendar view toggle - AppointmentList: date-grouped list with type/case filtering, summary cards - AppointmentCalendar: month grid with colored type dots, clickable days/appointments - AppointmentModal: create/edit/delete with case linking, type selection, location --- frontend/src/app/(app)/termine/page.tsx | 99 +++++++ .../appointments/AppointmentCalendar.tsx | 160 ++++++++++ .../appointments/AppointmentList.tsx | 265 +++++++++++++++++ .../appointments/AppointmentModal.tsx | 280 ++++++++++++++++++ 4 files changed, 804 insertions(+) create mode 100644 frontend/src/app/(app)/termine/page.tsx create mode 100644 frontend/src/components/appointments/AppointmentCalendar.tsx create mode 100644 frontend/src/components/appointments/AppointmentList.tsx create mode 100644 frontend/src/components/appointments/AppointmentModal.tsx diff --git a/frontend/src/app/(app)/termine/page.tsx b/frontend/src/app/(app)/termine/page.tsx new file mode 100644 index 0000000..6efe2d0 --- /dev/null +++ b/frontend/src/app/(app)/termine/page.tsx @@ -0,0 +1,99 @@ +"use client"; + +import { AppointmentList } from "@/components/appointments/AppointmentList"; +import { AppointmentCalendar } from "@/components/appointments/AppointmentCalendar"; +import { AppointmentModal } from "@/components/appointments/AppointmentModal"; +import { useQuery } from "@tanstack/react-query"; +import { api } from "@/lib/api"; +import type { Appointment } from "@/lib/types"; +import { Calendar, List, Plus } from "lucide-react"; +import { useState } from "react"; + +type ViewMode = "list" | "calendar"; + +export default function TerminePage() { + const [view, setView] = useState("list"); + const [modalOpen, setModalOpen] = useState(false); + const [editingAppointment, setEditingAppointment] = useState(null); + + const { data: appointments } = useQuery({ + queryKey: ["appointments"], + queryFn: () => api.get("/api/appointments"), + }); + + function handleEdit(appointment: Appointment) { + setEditingAppointment(appointment); + setModalOpen(true); + } + + function handleCreate() { + setEditingAppointment(null); + setModalOpen(true); + } + + function handleClose() { + setModalOpen(false); + setEditingAppointment(null); + } + + return ( +
+
+
+

Termine

+

+ Alle Termine im Uberblick +

+
+
+ +
+ + +
+
+
+ + {view === "list" ? ( + + ) : ( + + )} + + +
+ ); +} diff --git a/frontend/src/components/appointments/AppointmentCalendar.tsx b/frontend/src/components/appointments/AppointmentCalendar.tsx new file mode 100644 index 0000000..d3af5fa --- /dev/null +++ b/frontend/src/components/appointments/AppointmentCalendar.tsx @@ -0,0 +1,160 @@ +"use client"; + +import type { Appointment } from "@/lib/types"; +import { + format, + startOfMonth, + endOfMonth, + startOfWeek, + endOfWeek, + eachDayOfInterval, + isSameMonth, + isToday, + parseISO, + addMonths, + subMonths, +} from "date-fns"; +import { de } from "date-fns/locale"; +import { ChevronLeft, ChevronRight } from "lucide-react"; +import { useState, useMemo } from "react"; + +const TYPE_DOT_COLORS: Record = { + hearing: "bg-blue-500", + meeting: "bg-violet-500", + consultation: "bg-emerald-500", + deadline_hearing: "bg-amber-500", + other: "bg-neutral-400", +}; + +interface AppointmentCalendarProps { + appointments: Appointment[]; + onDayClick?: (date: string) => void; + onAppointmentClick?: (appointment: Appointment) => void; +} + +export function AppointmentCalendar({ + appointments, + onDayClick, + onAppointmentClick, +}: AppointmentCalendarProps) { + const [currentMonth, setCurrentMonth] = useState(new Date()); + + const monthStart = startOfMonth(currentMonth); + const monthEnd = endOfMonth(currentMonth); + const calStart = startOfWeek(monthStart, { weekStartsOn: 1 }); + const calEnd = endOfWeek(monthEnd, { weekStartsOn: 1 }); + const days = eachDayOfInterval({ start: calStart, end: calEnd }); + + const appointmentsByDay = useMemo(() => { + const map = new Map(); + for (const a of appointments) { + const key = a.start_at.slice(0, 10); + const existing = map.get(key) || []; + existing.push(a); + map.set(key, existing); + } + return map; + }, [appointments]); + + const weekDays = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"]; + + return ( +
+ {/* Header */} +
+ + + {format(currentMonth, "MMMM yyyy", { locale: de })} + + +
+ + {/* Weekday labels */} +
+ {weekDays.map((d) => ( +
+ {d} +
+ ))} +
+ + {/* Days grid */} +
+ {days.map((day, i) => { + const key = format(day, "yyyy-MM-dd"); + const dayAppointments = appointmentsByDay.get(key) || []; + const inMonth = isSameMonth(day, currentMonth); + const today = isToday(day); + + return ( +
onDayClick?.(key)} + className={`min-h-[5rem] cursor-pointer border-b border-r border-neutral-100 p-1.5 transition-colors hover:bg-neutral-50 ${ + !inMonth ? "bg-neutral-50/50" : "" + }`} + > +
+ {today ? ( + + {format(day, "d")} + + ) : ( + format(day, "d") + )} +
+
+ {dayAppointments.slice(0, 3).map((appt) => { + const dotColor = + TYPE_DOT_COLORS[appt.appointment_type ?? "other"] ?? TYPE_DOT_COLORS.other; + return ( +
{ + e.stopPropagation(); + onAppointmentClick?.(appt); + }} + className="flex items-center gap-1 truncate rounded px-0.5 hover:bg-neutral-100" + title={`${format(parseISO(appt.start_at), "HH:mm")} ${appt.title}`} + > +
+ + + {format(parseISO(appt.start_at), "HH:mm")} + {" "} + {appt.title} + +
+ ); + })} + {dayAppointments.length > 3 && ( +
+ +{dayAppointments.length - 3} mehr +
+ )} +
+
+ ); + })} +
+
+ ); +} diff --git a/frontend/src/components/appointments/AppointmentList.tsx b/frontend/src/components/appointments/AppointmentList.tsx new file mode 100644 index 0000000..01d7bd4 --- /dev/null +++ b/frontend/src/components/appointments/AppointmentList.tsx @@ -0,0 +1,265 @@ +"use client"; + +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { api } from "@/lib/api"; +import type { Appointment, Case } from "@/lib/types"; +import { format, parseISO, isToday, isTomorrow, isThisWeek, isPast } from "date-fns"; +import { de } from "date-fns/locale"; +import { Calendar, Filter, MapPin, Trash2 } from "lucide-react"; +import { toast } from "sonner"; +import { useState, useMemo } from "react"; + +const TYPE_LABELS: Record = { + hearing: "Verhandlung", + meeting: "Besprechung", + consultation: "Beratung", + deadline_hearing: "Fristanhorung", + other: "Sonstiges", +}; + +const TYPE_COLORS: Record = { + hearing: "bg-blue-100 text-blue-700", + meeting: "bg-violet-100 text-violet-700", + consultation: "bg-emerald-100 text-emerald-700", + deadline_hearing: "bg-amber-100 text-amber-700", + other: "bg-neutral-100 text-neutral-600", +}; + +interface AppointmentListProps { + onEdit: (appointment: Appointment) => void; +} + +function groupByDate(appointments: Appointment[]): Map { + const groups = new Map(); + for (const a of appointments) { + const key = a.start_at.slice(0, 10); + const group = groups.get(key) || []; + group.push(a); + groups.set(key, group); + } + return groups; +} + +function formatDateLabel(dateStr: string): string { + const d = parseISO(dateStr); + if (isToday(d)) return "Heute"; + if (isTomorrow(d)) return "Morgen"; + return format(d, "EEEE, d. MMMM yyyy", { locale: de }); +} + +export function AppointmentList({ onEdit }: AppointmentListProps) { + const queryClient = useQueryClient(); + const [caseFilter, setCaseFilter] = useState("all"); + const [typeFilter, setTypeFilter] = useState("all"); + + const { data: appointments, isLoading } = useQuery({ + queryKey: ["appointments"], + queryFn: () => api.get("/api/appointments"), + }); + + const { data: cases } = useQuery({ + queryKey: ["cases"], + queryFn: () => api.get<{ cases: Case[]; total: number }>("/api/cases"), + }); + + const deleteMutation = useMutation({ + mutationFn: (id: string) => api.delete(`/api/appointments/${id}`), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["appointments"] }); + toast.success("Termin geloscht"); + }, + onError: () => toast.error("Fehler beim Loschen"), + }); + + const caseMap = useMemo(() => { + const map = new Map(); + cases?.cases?.forEach((c) => map.set(c.id, c)); + return map; + }, [cases]); + + const filtered = useMemo(() => { + if (!appointments) return []; + return appointments + .filter((a) => { + if (caseFilter !== "all" && a.case_id !== caseFilter) return false; + if (typeFilter !== "all" && a.appointment_type !== typeFilter) return false; + return true; + }) + .sort((a, b) => a.start_at.localeCompare(b.start_at)); + }, [appointments, caseFilter, typeFilter]); + + const grouped = useMemo(() => groupByDate(filtered), [filtered]); + + const counts = useMemo(() => { + if (!appointments) return { today: 0, thisWeek: 0, total: 0 }; + let today = 0; + let thisWeek = 0; + for (const a of appointments) { + const d = parseISO(a.start_at); + if (isToday(d)) today++; + if (isThisWeek(d, { weekStartsOn: 1 })) thisWeek++; + } + return { today, thisWeek, total: appointments.length }; + }, [appointments]); + + if (isLoading) { + return ( +
+ {[1, 2, 3, 4].map((i) => ( +
+ ))} +
+ ); + } + + return ( +
+ {/* Summary cards */} +
+
+
{counts.today}
+
Heute
+
+
+
{counts.thisWeek}
+
Diese Woche
+
+
+
{counts.total}
+
Gesamt
+
+
+ + {/* Filters */} +
+
+ + Filter: +
+ + {cases?.cases && cases.cases.length > 0 && ( + + )} +
+ + {/* Grouped list */} + {filtered.length === 0 ? ( +
+ +

Keine Termine gefunden

+
+ ) : ( +
+ {Array.from(grouped.entries()).map(([dateKey, dayAppointments]) => { + const dateIsPast = isPast(parseISO(dateKey + "T23:59:59")); + return ( +
+
+ {formatDateLabel(dateKey)} +
+
+ {dayAppointments.map((appt) => { + const caseInfo = appt.case_id ? caseMap.get(appt.case_id) : null; + const typeBadge = appt.appointment_type + ? TYPE_COLORS[appt.appointment_type] ?? TYPE_COLORS.other + : null; + const typeLabel = appt.appointment_type + ? TYPE_LABELS[appt.appointment_type] ?? appt.appointment_type + : null; + + return ( +
onEdit(appt)} + className={`flex cursor-pointer items-start gap-3 rounded-lg border px-4 py-3 transition-colors hover:bg-neutral-50 ${ + dateIsPast + ? "border-neutral-150 bg-neutral-50/50" + : "border-neutral-200 bg-white" + }`} + > +
+
+ {format(parseISO(appt.start_at), "HH:mm")} +
+ {appt.end_at && ( +
+ {format(parseISO(appt.end_at), "HH:mm")} +
+ )} +
+
+
+ + {appt.title} + + {typeBadge && typeLabel && ( + + {typeLabel} + + )} +
+
+ {appt.location && ( + + + {appt.location} + + )} + {appt.location && caseInfo && ยท} + {caseInfo && ( + + {caseInfo.case_number} โ€” {caseInfo.title} + + )} +
+ {appt.description && ( +

+ {appt.description} +

+ )} +
+ +
+ ); + })} +
+
+ ); + })} +
+ )} +
+ ); +} diff --git a/frontend/src/components/appointments/AppointmentModal.tsx b/frontend/src/components/appointments/AppointmentModal.tsx new file mode 100644 index 0000000..75ef8ff --- /dev/null +++ b/frontend/src/components/appointments/AppointmentModal.tsx @@ -0,0 +1,280 @@ +"use client"; + +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { api } from "@/lib/api"; +import type { Appointment, Case } from "@/lib/types"; +import { format, parseISO } from "date-fns"; +import { X } from "lucide-react"; +import { toast } from "sonner"; +import { useEffect, useState } from "react"; + +const APPOINTMENT_TYPES = [ + { value: "hearing", label: "Verhandlung" }, + { value: "meeting", label: "Besprechung" }, + { value: "consultation", label: "Beratung" }, + { value: "deadline_hearing", label: "Fristanhorung" }, + { value: "other", label: "Sonstiges" }, +]; + +interface AppointmentModalProps { + open: boolean; + onClose: () => void; + appointment?: Appointment | null; +} + +function toLocalDatetime(iso: string): string { + const d = parseISO(iso); + return format(d, "yyyy-MM-dd'T'HH:mm"); +} + +export function AppointmentModal({ open, onClose, appointment }: AppointmentModalProps) { + const queryClient = useQueryClient(); + const isEdit = !!appointment; + + const [title, setTitle] = useState(""); + const [description, setDescription] = useState(""); + const [startAt, setStartAt] = useState(""); + const [endAt, setEndAt] = useState(""); + const [location, setLocation] = useState(""); + const [appointmentType, setAppointmentType] = useState(""); + const [caseId, setCaseId] = useState(""); + + const { data: cases } = useQuery({ + queryKey: ["cases"], + queryFn: () => api.get<{ cases: Case[]; total: number }>("/api/cases"), + }); + + useEffect(() => { + if (appointment) { + setTitle(appointment.title); + setDescription(appointment.description ?? ""); + setStartAt(toLocalDatetime(appointment.start_at)); + setEndAt(appointment.end_at ? toLocalDatetime(appointment.end_at) : ""); + setLocation(appointment.location ?? ""); + setAppointmentType(appointment.appointment_type ?? ""); + setCaseId(appointment.case_id ?? ""); + } else { + setTitle(""); + setDescription(""); + setStartAt(""); + setEndAt(""); + setLocation(""); + setAppointmentType(""); + setCaseId(""); + } + }, [appointment]); + + const createMutation = useMutation({ + mutationFn: (body: Record) => + api.post("/api/appointments", body), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["appointments"] }); + queryClient.invalidateQueries({ queryKey: ["dashboard"] }); + toast.success("Termin erstellt"); + onClose(); + }, + onError: () => toast.error("Fehler beim Erstellen des Termins"), + }); + + const updateMutation = useMutation({ + mutationFn: (body: Record) => + api.put(`/api/appointments/${appointment!.id}`, body), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["appointments"] }); + queryClient.invalidateQueries({ queryKey: ["dashboard"] }); + toast.success("Termin aktualisiert"); + onClose(); + }, + onError: () => toast.error("Fehler beim Aktualisieren des Termins"), + }); + + const deleteMutation = useMutation({ + mutationFn: () => api.delete(`/api/appointments/${appointment!.id}`), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["appointments"] }); + queryClient.invalidateQueries({ queryKey: ["dashboard"] }); + toast.success("Termin geloscht"); + onClose(); + }, + onError: () => toast.error("Fehler beim Loschen des Termins"), + }); + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!title.trim() || !startAt) return; + + const body: Record = { + title: title.trim(), + start_at: new Date(startAt).toISOString(), + }; + if (description.trim()) body.description = description.trim(); + if (endAt) body.end_at = new Date(endAt).toISOString(); + if (location.trim()) body.location = location.trim(); + if (appointmentType) body.appointment_type = appointmentType; + if (caseId) body.case_id = caseId; + + if (isEdit) { + updateMutation.mutate(body); + } else { + createMutation.mutate(body); + } + } + + const isPending = createMutation.isPending || updateMutation.isPending; + + if (!open) return null; + + return ( +
+
+
+

+ {isEdit ? "Termin bearbeiten" : "Neuer Termin"} +

+ +
+ +
+
+ + setTitle(e.target.value)} + required + className="w-full rounded-md border border-neutral-200 px-3 py-1.5 text-sm outline-none focus:border-neutral-400 focus:ring-1 focus:ring-neutral-400" + placeholder="z.B. Mundliche Verhandlung" + /> +
+ +
+
+ + setStartAt(e.target.value)} + required + className="w-full rounded-md border border-neutral-200 px-3 py-1.5 text-sm outline-none focus:border-neutral-400 focus:ring-1 focus:ring-neutral-400" + /> +
+
+ + setEndAt(e.target.value)} + className="w-full rounded-md border border-neutral-200 px-3 py-1.5 text-sm outline-none focus:border-neutral-400 focus:ring-1 focus:ring-neutral-400" + /> +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + setLocation(e.target.value)} + className="w-full rounded-md border border-neutral-200 px-3 py-1.5 text-sm outline-none focus:border-neutral-400 focus:ring-1 focus:ring-neutral-400" + placeholder="z.B. UPC Munchen, Saal 3" + /> +
+ +
+ +