From 3a56d4cf119872505440a6f8fd253a684c3cf3f1 Mon Sep 17 00:00:00 2001 From: m Date: Wed, 25 Mar 2026 13:50:20 +0100 Subject: [PATCH] feat: add frontend case list, detail, and creation pages (Phase 1F) - Case list page (/cases) with search, status/type filters, status badges - Case creation page (/cases/new) with reusable CaseForm component - Case detail page (/cases/[id]) with tabs: Timeline, Deadlines, Documents, Parties - CaseTimeline component for chronological case_events display - PartyList component with inline party CRUD (add/delete) - Updated sidebar navigation to route to /cases --- frontend/src/app/(app)/cases/[id]/page.tsx | 267 ++++++++++++++++++ frontend/src/app/(app)/cases/new/page.tsx | 49 ++++ frontend/src/app/(app)/cases/page.tsx | 172 +++++++++++ frontend/src/components/cases/CaseForm.tsx | 165 +++++++++++ .../src/components/cases/CaseTimeline.tsx | 60 ++++ frontend/src/components/cases/PartyList.tsx | 182 ++++++++++++ frontend/src/components/layout/Sidebar.tsx | 2 +- 7 files changed, 896 insertions(+), 1 deletion(-) create mode 100644 frontend/src/app/(app)/cases/[id]/page.tsx create mode 100644 frontend/src/app/(app)/cases/new/page.tsx create mode 100644 frontend/src/app/(app)/cases/page.tsx create mode 100644 frontend/src/components/cases/CaseForm.tsx create mode 100644 frontend/src/components/cases/CaseTimeline.tsx create mode 100644 frontend/src/components/cases/PartyList.tsx diff --git a/frontend/src/app/(app)/cases/[id]/page.tsx b/frontend/src/app/(app)/cases/[id]/page.tsx new file mode 100644 index 0000000..6fc710a --- /dev/null +++ b/frontend/src/app/(app)/cases/[id]/page.tsx @@ -0,0 +1,267 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { useParams } from "next/navigation"; +import { api } from "@/lib/api"; +import type { Case, CaseEvent, Party, Deadline, Document } from "@/lib/types"; +import { CaseTimeline } from "@/components/cases/CaseTimeline"; +import { PartyList } from "@/components/cases/PartyList"; +import { ArrowLeft, Clock, FileText, Users, Activity } from "lucide-react"; +import { format } from "date-fns"; +import { de } from "date-fns/locale"; +import Link from "next/link"; +import { useState } from "react"; + +interface CaseDetail extends Case { + parties: Party[]; + recent_events: CaseEvent[]; + deadlines_count: number; +} + +const STATUS_BADGE: Record = { + active: "bg-emerald-50 text-emerald-700", + pending: "bg-amber-50 text-amber-700", + closed: "bg-neutral-100 text-neutral-600", + archived: "bg-neutral-100 text-neutral-400", +}; + +const TABS = [ + { key: "timeline", label: "Verlauf", icon: Activity }, + { key: "deadlines", label: "Fristen", icon: Clock }, + { key: "documents", label: "Dokumente", icon: FileText }, + { key: "parties", label: "Parteien", icon: Users }, +] as const; + +type TabKey = (typeof TABS)[number]["key"]; + +export default function CaseDetailPage() { + const { id } = useParams<{ id: string }>(); + const [activeTab, setActiveTab] = useState("timeline"); + + const { data: caseDetail, isLoading } = useQuery({ + queryKey: ["case", id], + queryFn: () => api.get(`/cases/${id}`), + }); + + const { data: deadlinesData } = useQuery({ + queryKey: ["case-deadlines", id], + queryFn: () => + api.get<{ deadlines: Deadline[]; total: number }>( + `/deadlines?case_id=${id}`, + ), + enabled: activeTab === "deadlines", + }); + + const { data: documentsData } = useQuery({ + queryKey: ["case-documents", id], + queryFn: () => api.get(`/cases/${id}/documents`), + enabled: activeTab === "documents", + }); + + if (isLoading) { + return ( +
+ Laden... +
+ ); + } + + if (!caseDetail) { + return ( +
+ Akte nicht gefunden. +
+ ); + } + + const deadlines = deadlinesData?.deadlines ?? []; + const documents = documentsData ?? []; + + return ( +
+ + + Zuruck zu Akten + + +
+
+
+

+ {caseDetail.title} +

+ + {caseDetail.status} + +
+
+ Az. {caseDetail.case_number} + {caseDetail.case_type && {caseDetail.case_type}} + {caseDetail.court && {caseDetail.court}} + {caseDetail.court_ref && ({caseDetail.court_ref})} +
+
+
+

+ Erstellt:{" "} + {format(new Date(caseDetail.created_at), "d. MMM yyyy", { + locale: de, + })} +

+

+ Aktualisiert:{" "} + {format(new Date(caseDetail.updated_at), "d. MMM yyyy", { + locale: de, + })} +

+
+
+ + {caseDetail.ai_summary && ( +
+ {caseDetail.ai_summary} +
+ )} + +
+ +
+ +
+ {activeTab === "timeline" && ( + + )} + + {activeTab === "deadlines" && ( + + )} + + {activeTab === "documents" && ( + + )} + + {activeTab === "parties" && ( + + )} +
+
+ ); +} + +function DeadlinesList({ deadlines }: { deadlines: Deadline[] }) { + if (deadlines.length === 0) { + return ( +

+ Keine Fristen vorhanden. +

+ ); + } + + const DEADLINE_STATUS: Record = { + pending: "bg-amber-50 text-amber-700", + completed: "bg-emerald-50 text-emerald-700", + overdue: "bg-red-50 text-red-700", + }; + + return ( +
+ {deadlines.map((d) => ( +
+
+

{d.title}

+ {d.description && ( +

+ {d.description} +

+ )} +
+
+ + {d.status} + + + {format(new Date(d.due_date), "d. MMM yyyy", { locale: de })} + +
+
+ ))} +
+ ); +} + +function DocumentsList({ documents }: { documents: Document[] }) { + if (documents.length === 0) { + return ( +

+ Keine Dokumente vorhanden. +

+ ); + } + + return ( +
+ {documents.map((doc) => ( +
+
+ +
+

+ {doc.title} +

+
+ {doc.doc_type && {doc.doc_type}} + {doc.file_size && ( + {(doc.file_size / 1024).toFixed(0)} KB + )} +
+
+
+ + Herunterladen + +
+ ))} +
+ ); +} diff --git a/frontend/src/app/(app)/cases/new/page.tsx b/frontend/src/app/(app)/cases/new/page.tsx new file mode 100644 index 0000000..93af4cf --- /dev/null +++ b/frontend/src/app/(app)/cases/new/page.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useRouter } from "next/navigation"; +import { toast } from "sonner"; +import { api } from "@/lib/api"; +import type { Case } from "@/lib/types"; +import { CaseForm, type CaseFormData } from "@/components/cases/CaseForm"; +import { ArrowLeft } from "lucide-react"; +import Link from "next/link"; + +export default function NewCasePage() { + const router = useRouter(); + const queryClient = useQueryClient(); + + const mutation = useMutation({ + mutationFn: (data: CaseFormData) => api.post("/cases", data), + onSuccess: (created) => { + queryClient.invalidateQueries({ queryKey: ["cases"] }); + toast.success("Akte angelegt"); + router.push(`/cases/${created.id}`); + }, + onError: () => { + toast.error("Fehler beim Anlegen der Akte"); + }, + }); + + return ( +
+ + + Zuruck zu Akten + +

Neue Akte

+

+ Neue Akte im System anlegen +

+
+ mutation.mutate(data)} + isSubmitting={mutation.isPending} + /> +
+
+ ); +} diff --git a/frontend/src/app/(app)/cases/page.tsx b/frontend/src/app/(app)/cases/page.tsx new file mode 100644 index 0000000..e81d50a --- /dev/null +++ b/frontend/src/app/(app)/cases/page.tsx @@ -0,0 +1,172 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { api } from "@/lib/api"; +import type { Case } from "@/lib/types"; +import Link from "next/link"; +import { useSearchParams, useRouter } from "next/navigation"; +import { Plus, Search } from "lucide-react"; +import { useState } from "react"; + +const STATUS_OPTIONS = [ + { value: "", label: "Alle Status" }, + { value: "active", label: "Aktiv" }, + { value: "pending", label: "Anhangig" }, + { value: "closed", label: "Geschlossen" }, + { value: "archived", label: "Archiviert" }, +]; + +const TYPE_OPTIONS = [ + { value: "", label: "Alle Typen" }, + { value: "INF", label: "Verletzungsklage" }, + { value: "REV", label: "Widerruf" }, + { value: "CCR", label: "Einstweilige Verfugung" }, + { value: "APP", label: "Berufung" }, + { value: "PI", label: "Vorlaufiger Rechtsschutz" }, + { value: "ZPO_CIVIL", label: "ZPO Zivilverfahren" }, +]; + +const STATUS_BADGE: Record = { + active: "bg-emerald-50 text-emerald-700", + pending: "bg-amber-50 text-amber-700", + closed: "bg-neutral-100 text-neutral-600", + archived: "bg-neutral-100 text-neutral-400", +}; + +export default function CasesPage() { + const router = useRouter(); + const searchParams = useSearchParams(); + + const [search, setSearch] = useState(searchParams.get("search") ?? ""); + const [status, setStatus] = useState(searchParams.get("status") ?? ""); + const [type, setType] = useState(searchParams.get("type") ?? ""); + + const { data, isLoading } = useQuery({ + queryKey: ["cases", { search, status, type }], + queryFn: () => { + const params = new URLSearchParams(); + if (search) params.set("search", search); + if (status) params.set("status", status); + if (type) params.set("type", type); + params.set("limit", "50"); + const qs = params.toString(); + return api.get<{ cases: Case[]; total: number }>( + `/cases${qs ? `?${qs}` : ""}`, + ); + }, + }); + + const cases = data?.cases ?? []; + + return ( +
+
+
+

Akten

+

+ {data ? `${data.total} Akten` : "Laden..."} +

+
+ + + Neue Akte + +
+ +
+
+ + setSearch(e.target.value)} + className="w-full rounded-md border border-neutral-200 bg-white py-1.5 pl-9 pr-3 text-sm outline-none focus:border-neutral-400 focus:ring-1 focus:ring-neutral-400" + /> +
+ + +
+ +
+ {isLoading ? ( +
+ Laden... +
+ ) : cases.length === 0 ? ( +
+ Keine Akten gefunden. +
+ ) : ( +
+ + + + + + + + + + + + + {cases.map((c) => ( + router.push(`/cases/${c.id}`)} + className="cursor-pointer hover:bg-neutral-50" + > + + + + + + + + ))} + +
AktenzeichenTitelTypGerichtStatusErstellt
+ {c.case_number} + {c.title} + {c.case_type ?? "-"} + + {c.court ?? "-"} + + + {c.status} + + + {new Date(c.created_at).toLocaleDateString("de-DE")} +
+
+ )} +
+
+ ); +} diff --git a/frontend/src/components/cases/CaseForm.tsx b/frontend/src/components/cases/CaseForm.tsx new file mode 100644 index 0000000..0254ad9 --- /dev/null +++ b/frontend/src/components/cases/CaseForm.tsx @@ -0,0 +1,165 @@ +"use client"; + +import { useState } from "react"; + +const TYPE_OPTIONS = [ + { value: "", label: "-- Typ wahlen --" }, + { value: "INF", label: "Verletzungsklage (INF)" }, + { value: "REV", label: "Widerruf (REV)" }, + { value: "CCR", label: "Einstweilige Verfugung (CCR)" }, + { value: "APP", label: "Berufung (APP)" }, + { value: "PI", label: "Vorlaufiger Rechtsschutz (PI)" }, + { value: "ZPO_CIVIL", label: "ZPO Zivilverfahren" }, +]; + +export interface CaseFormData { + case_number: string; + title: string; + case_type?: string; + court?: string; + court_ref?: string; + status: string; +} + +interface CaseFormProps { + initialData?: Partial; + onSubmit: (data: CaseFormData) => void; + isSubmitting?: boolean; + submitLabel?: string; +} + +export function CaseForm({ + initialData, + onSubmit, + isSubmitting, + submitLabel = "Akte anlegen", +}: CaseFormProps) { + const [form, setForm] = useState({ + case_number: initialData?.case_number ?? "", + title: initialData?.title ?? "", + case_type: initialData?.case_type ?? "", + court: initialData?.court ?? "", + court_ref: initialData?.court_ref ?? "", + status: initialData?.status ?? "active", + }); + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + const data: CaseFormData = { + ...form, + case_type: form.case_type || undefined, + court: form.court || undefined, + court_ref: form.court_ref || undefined, + }; + onSubmit(data); + } + + function update(field: keyof CaseFormData, value: string) { + setForm((prev) => ({ ...prev, [field]: value })); + } + + const inputClass = + "w-full rounded-md border border-neutral-200 bg-white px-3 py-1.5 text-sm outline-none focus:border-neutral-400 focus:ring-1 focus:ring-neutral-400"; + + return ( +
+
+
+ + update("case_number", e.target.value)} + placeholder="z.B. 2026/001" + className={inputClass} + /> +
+
+ + +
+
+ +
+ + update("title", e.target.value)} + placeholder="Bezeichnung der Akte" + className={inputClass} + /> +
+ +
+
+ + +
+
+ + update("court", e.target.value)} + placeholder="z.B. UPC Munich Central Division" + className={inputClass} + /> +
+
+ +
+ + update("court_ref", e.target.value)} + placeholder="z.B. UPC_CFI_123/2026" + className={inputClass} + /> +
+ +
+ +
+
+ ); +} diff --git a/frontend/src/components/cases/CaseTimeline.tsx b/frontend/src/components/cases/CaseTimeline.tsx new file mode 100644 index 0000000..6973b15 --- /dev/null +++ b/frontend/src/components/cases/CaseTimeline.tsx @@ -0,0 +1,60 @@ +"use client"; + +import type { CaseEvent } from "@/lib/types"; +import { format } from "date-fns"; +import { de } from "date-fns/locale"; + +const EVENT_ICONS: Record = { + case_created: "bg-emerald-500", + status_changed: "bg-amber-500", + party_added: "bg-blue-500", + case_archived: "bg-neutral-400", + document_uploaded: "bg-violet-500", + deadline_created: "bg-red-500", +}; + +interface CaseTimelineProps { + events: CaseEvent[]; +} + +export function CaseTimeline({ events }: CaseTimelineProps) { + if (events.length === 0) { + return ( +

+ Keine Ereignisse vorhanden. +

+ ); + } + + return ( +
+ {events.map((event, i) => ( +
+ {i < events.length - 1 && ( +
+ )} +
+
+

+ {event.title} +

+ {event.description && ( +

+ {event.description} +

+ )} +

+ {format( + new Date(event.event_date ?? event.created_at), + "d. MMM yyyy, HH:mm", + { locale: de }, + )} +

+
+
+ ))} +
+ ); +} diff --git a/frontend/src/components/cases/PartyList.tsx b/frontend/src/components/cases/PartyList.tsx new file mode 100644 index 0000000..fd2b9ab --- /dev/null +++ b/frontend/src/components/cases/PartyList.tsx @@ -0,0 +1,182 @@ +"use client"; + +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; +import { toast } from "sonner"; +import { api } from "@/lib/api"; +import type { Party } from "@/lib/types"; +import { Plus, Trash2, X } from "lucide-react"; + +interface PartyListProps { + caseId: string; + parties: Party[]; +} + +interface PartyFormData { + name: string; + role: string; + representative: string; +} + +const ROLE_OPTIONS = [ + "Klager", + "Beklagter", + "Nebenintervenient", + "Patentinhaber", + "Streithelfer", +]; + +export function PartyList({ caseId, parties }: PartyListProps) { + const queryClient = useQueryClient(); + const [showForm, setShowForm] = useState(false); + const [form, setForm] = useState({ + name: "", + role: "", + representative: "", + }); + + const addMutation = useMutation({ + mutationFn: (data: PartyFormData) => + api.post(`/cases/${caseId}/parties`, { + name: data.name, + role: data.role || undefined, + representative: data.representative || undefined, + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["case", caseId] }); + toast.success("Partei hinzugefugt"); + setShowForm(false); + setForm({ name: "", role: "", representative: "" }); + }, + onError: () => toast.error("Fehler beim Hinzufugen"), + }); + + const deleteMutation = useMutation({ + mutationFn: (partyId: string) => api.delete(`/parties/${partyId}`), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["case", caseId] }); + toast.success("Partei entfernt"); + }, + onError: () => toast.error("Fehler beim Entfernen"), + }); + + const inputClass = + "w-full rounded-md border border-neutral-200 bg-white px-3 py-1.5 text-sm outline-none focus:border-neutral-400 focus:ring-1 focus:ring-neutral-400"; + + return ( +
+
+

+ Parteien ({parties.length}) +

+ {!showForm && ( + + )} +
+ + {parties.length === 0 && !showForm && ( +

+ Keine Parteien vorhanden. +

+ )} + +
+ {parties.map((party) => ( +
+
+

+ {party.name} +

+
+ {party.role && {party.role}} + {party.representative && ( + Vertreter: {party.representative} + )} +
+
+ +
+ ))} +
+ + {showForm && ( +
+
+ + Neue Partei + + +
+
{ + e.preventDefault(); + addMutation.mutate(form); + }} + className="mt-3 space-y-3" + > + setForm({ ...form, name: e.target.value })} + className={inputClass} + /> +
+ + + setForm({ ...form, representative: e.target.value }) + } + className={inputClass} + /> +
+
+ +
+
+
+ )} +
+ ); +} diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 218b06c..fed93fe 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -13,7 +13,7 @@ import { const navigation = [ { name: "Dashboard", href: "/", icon: LayoutDashboard }, - { name: "Akten", href: "/akten", icon: FolderOpen }, + { name: "Akten", href: "/cases", icon: FolderOpen }, { name: "Fristen", href: "/fristen", icon: Clock }, { name: "Termine", href: "/termine", icon: Calendar }, { name: "AI Analyse", href: "/ai", icon: Brain },