diff --git a/frontend/src/app/(app)/ai/extract/page.tsx b/frontend/src/app/(app)/ai/extract/page.tsx index 932c04c..60ef44b 100644 --- a/frontend/src/app/(app)/ai/extract/page.tsx +++ b/frontend/src/app/(app)/ai/extract/page.tsx @@ -90,14 +90,14 @@ export default function AIExtractPage() { await Promise.all(promises); toast.success( - `${deadlines.length} Frist(en) erfolgreich uebernommen.`, + `${deadlines.length} Frist(en) erfolgreich übernommen.`, ); - router.push(`/akten/${selectedCaseId}`); + router.push(`/cases/${selectedCaseId}`); } catch (err: unknown) { const message = err && typeof err === "object" && "error" in err ? (err as { error: string }).error - : "Uebernahme fehlgeschlagen"; + : "Übernahme fehlgeschlagen"; toast.error(message); } finally { setIsAdopting(false); @@ -105,7 +105,7 @@ export default function AIExtractPage() { } return ( -
+
@@ -118,7 +118,7 @@ export default function AIExtractPage() {
-
+
{results !== null && ( -
+
= { archived: "bg-neutral-100 text-neutral-400", }; +const STATUS_LABEL: Record = { + active: "Aktiv", + pending: "Anhängig", + closed: "Geschlossen", + archived: "Archiviert", +}; + const TABS = [ { key: "timeline", label: "Verlauf", icon: Activity }, { key: "deadlines", label: "Fristen", icon: Clock }, @@ -36,11 +49,43 @@ const TABS = [ type TabKey = (typeof TABS)[number]["key"]; +function CaseDetailSkeleton() { + return ( +
+ +
+
+ + +
+
+ + +
+
+
+ {[1, 2, 3, 4].map((i) => ( + + ))} +
+
+ {[1, 2, 3].map((i) => ( + + ))} +
+
+ ); +} + export default function CaseDetailPage() { const { id } = useParams<{ id: string }>(); const [activeTab, setActiveTab] = useState("timeline"); - const { data: caseDetail, isLoading } = useQuery({ + const { + data: caseDetail, + isLoading, + error, + } = useQuery({ queryKey: ["case", id], queryFn: () => api.get(`/cases/${id}`), }); @@ -57,21 +102,32 @@ export default function CaseDetailPage() { const { data: documentsData } = useQuery({ queryKey: ["case-documents", id], queryFn: () => api.get(`/cases/${id}/documents`), - enabled: activeTab === "documents" || activeTab === "timeline", + enabled: activeTab === "documents", }); if (isLoading) { - return ( -
- Laden... -
- ); + return ; } - if (!caseDetail) { + if (error || !caseDetail) { return ( -
- Akte nicht gefunden. +
+
+ +
+

+ Akte nicht gefunden +

+

+ Die Akte existiert nicht oder Sie haben keine Berechtigung. +

+ + + Zurück zu Akten +
); } @@ -80,28 +136,28 @@ export default function CaseDetailPage() { const documents = documentsData ?? []; return ( -
+
- Zuruck zu Akten + Zurück zu Akten -
+
-
+

{caseDetail.title}

- {caseDetail.status} + {STATUS_LABEL[caseDetail.status] ?? caseDetail.status}
-
+
Az. {caseDetail.case_number} {caseDetail.case_type && {caseDetail.case_type}} {caseDetail.court && {caseDetail.court}} @@ -131,12 +187,12 @@ export default function CaseDetailPage() { )}
-
); } return ( -
+

Dashboard

@@ -44,20 +80,15 @@ export default function DashboardPage() {

- {/* 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)/fristen/page.tsx b/frontend/src/app/(app)/fristen/page.tsx index 830c60c..914c175 100644 --- a/frontend/src/app/(app)/fristen/page.tsx +++ b/frontend/src/app/(app)/fristen/page.tsx @@ -20,12 +20,12 @@ export default function FristenPage() { }); return ( -
-
+
+

Fristen

- Alle Fristen im Uberblick + Alle Fristen im Überblick

diff --git a/frontend/src/app/(app)/fristen/rechner/page.tsx b/frontend/src/app/(app)/fristen/rechner/page.tsx index 8d1850b..f6f254b 100644 --- a/frontend/src/app/(app)/fristen/rechner/page.tsx +++ b/frontend/src/app/(app)/fristen/rechner/page.tsx @@ -6,18 +6,20 @@ import Link from "next/link"; export default function FristenrechnerPage() { return ( -
+
- Zuruck zu Fristen + Zurück zu Fristen -

Fristenrechner

+

+ Fristenrechner +

- Berechnen Sie Fristen basierend auf Verfahrensart und Auslosedatum + Berechnen Sie Fristen basierend auf Verfahrensart und Auslösedatum

diff --git a/frontend/src/app/(app)/layout.tsx b/frontend/src/app/(app)/layout.tsx index d166fc6..8740d7d 100644 --- a/frontend/src/app/(app)/layout.tsx +++ b/frontend/src/app/(app)/layout.tsx @@ -13,7 +13,7 @@ export default function AppLayout({
-
{children}
+
{children}
); diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 15ececd..04c26ee 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -10,6 +10,19 @@ body { -moz-osx-font-smoothing: grayscale; } +/* Focus-visible ring for accessibility */ +*:focus-visible { + outline: 2px solid #404040; + outline-offset: 2px; + border-radius: 4px; +} + +input:focus-visible, +select:focus-visible, +textarea:focus-visible { + outline: none; +} + @keyframes count-up { 0% { transform: translateY(8px); @@ -24,3 +37,31 @@ body { .animate-count-up { animation: count-up 0.3s ease-out; } + +@keyframes fade-in { + 0% { + opacity: 0; + transform: translateY(4px); + } + 100% { + opacity: 1; + transform: translateY(0); + } +} + +.animate-fade-in { + animation: fade-in 0.2s ease-out; +} + +@keyframes slide-in-left { + 0% { + transform: translateX(-100%); + } + 100% { + transform: translateX(0); + } +} + +.animate-slide-in-left { + animation: slide-in-left 0.2s ease-out; +} diff --git a/frontend/src/components/ai/ExtractionForm.tsx b/frontend/src/components/ai/ExtractionForm.tsx index 79bacf3..926fe66 100644 --- a/frontend/src/components/ai/ExtractionForm.tsx +++ b/frontend/src/components/ai/ExtractionForm.tsx @@ -13,6 +13,9 @@ interface ExtractionFormProps { isLoading: boolean; } +const inputClass = + "w-full rounded-md border border-neutral-200 bg-white px-3 py-2 text-sm text-neutral-900 outline-none transition-colors focus:border-neutral-400 focus:ring-1 focus:ring-neutral-400"; + export function ExtractionForm({ cases, selectedCaseId, @@ -63,10 +66,10 @@ export function ExtractionForm({ id="case-select" value={selectedCaseId} onChange={(e) => onCaseChange(e.target.value)} - className="w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-sm text-neutral-900 focus:border-neutral-500 focus:outline-none focus:ring-1 focus:ring-neutral-500" + className={inputClass} disabled={isLoading} > - + {cases.map((c) => (
diff --git a/frontend/src/components/ai/ExtractionResults.tsx b/frontend/src/components/ai/ExtractionResults.tsx index 817f3ae..9264f6a 100644 --- a/frontend/src/components/ai/ExtractionResults.tsx +++ b/frontend/src/components/ai/ExtractionResults.tsx @@ -1,7 +1,7 @@ "use client"; import { useState } from "react"; -import { Trash2, Check, Pencil, X, Loader2 } from "lucide-react"; +import { Trash2, Check, Pencil, X, Loader2, Brain } from "lucide-react"; import type { ExtractedDeadline } from "@/lib/types"; interface ExtractionResultsProps { @@ -22,6 +22,9 @@ function confidenceLabel(confidence: number): string { return "Niedrig"; } +const editInputClass = + "w-full rounded border border-neutral-300 px-2 py-1 text-sm outline-none transition-colors focus:border-neutral-400 focus:ring-1 focus:ring-neutral-400"; + export function ExtractionResults({ deadlines: initialDeadlines, onAdopt, @@ -56,8 +59,11 @@ export function ExtractionResults({ if (deadlines.length === 0) { return ( -
-

+

+
+ +
+

Keine Fristen gefunden. Alle extrahierten Fristen wurden entfernt.

@@ -66,7 +72,7 @@ export function ExtractionResults({ return (
-
+

{deadlines.length} Frist{deadlines.length !== 1 ? "en" : ""} erkannt

@@ -78,18 +84,19 @@ export function ExtractionResults({ {isAdopting ? ( <> - Uebernehme... + Übernehme... ) : ( <> - Fristen uebernehmen + Fristen übernehmen )}
-
+ {/* Mobile: card layout, Desktop: table */} +
@@ -97,7 +104,7 @@ export function ExtractionResults({ Frist - {editingIndex === i && editForm ? ( <> @@ -127,7 +134,7 @@ export function ExtractionResults({ onChange={(e) => setEditForm({ ...editForm, title: e.target.value }) } - className="w-full rounded border border-neutral-300 px-2 py-1 text-sm" + className={editInputClass} /> - -
- Faelligkeitsdatum + Fälligkeitsdatum Rechtsgrundlage @@ -105,7 +112,7 @@ export function ExtractionResults({ Konfidenz + Quellenangabe @@ -117,7 +124,7 @@ export function ExtractionResults({ {deadlines.map((d, i) => (
@@ -140,7 +147,7 @@ export function ExtractionResults({ due_date: e.target.value || null, }) } - className="rounded border border-neutral-300 px-2 py-1 text-sm" + className={editInputClass} /> @@ -152,7 +159,7 @@ export function ExtractionResults({ rule_reference: e.target.value, }) } - className="w-full rounded border border-neutral-300 px-2 py-1 text-sm" + className={editInputClass} /> @@ -162,21 +169,21 @@ export function ExtractionResults({ {confidenceLabel(editForm.confidence)} + {editForm.source_quote}
+ {d.source_quote || "-"}
+ + {/* Mobile card layout */} +
+ {deadlines.map((d, i) => ( +
+
+

{d.title}

+
+ + +
+
+
+ + {d.due_date + ? new Date(d.due_date).toLocaleDateString("de-DE") + : `${d.duration_value} ${d.duration_unit}`} + + {d.rule_reference && ( + <> + · + {d.rule_reference} + + )} + + {confidenceLabel(d.confidence)} {Math.round(d.confidence * 100)} + % + +
+
+ ))} +
); } diff --git a/frontend/src/components/cases/CaseForm.tsx b/frontend/src/components/cases/CaseForm.tsx index 0254ad9..024c85a 100644 --- a/frontend/src/components/cases/CaseForm.tsx +++ b/frontend/src/components/cases/CaseForm.tsx @@ -3,12 +3,12 @@ import { useState } from "react"; const TYPE_OPTIONS = [ - { value: "", label: "-- Typ wahlen --" }, + { value: "", label: "-- Typ wählen --" }, { value: "INF", label: "Verletzungsklage (INF)" }, { value: "REV", label: "Widerruf (REV)" }, - { value: "CCR", label: "Einstweilige Verfugung (CCR)" }, + { value: "CCR", label: "Einstweilige Verfügung (CCR)" }, { value: "APP", label: "Berufung (APP)" }, - { value: "PI", label: "Vorlaufiger Rechtsschutz (PI)" }, + { value: "PI", label: "Vorläufiger Rechtsschutz (PI)" }, { value: "ZPO_CIVIL", label: "ZPO Zivilverfahren" }, ]; @@ -43,8 +43,23 @@ export function CaseForm({ status: initialData?.status ?? "active", }); + const [errors, setErrors] = useState>>({}); + + function validate(): boolean { + const newErrors: Partial> = {}; + if (!form.case_number.trim()) { + newErrors.case_number = "Aktenzeichen ist erforderlich"; + } + if (!form.title.trim()) { + newErrors.title = "Titel ist erforderlich"; + } + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + } + function handleSubmit(e: React.FormEvent) { e.preventDefault(); + if (!validate()) return; const data: CaseFormData = { ...form, case_type: form.case_type || undefined, @@ -56,26 +71,31 @@ export function CaseForm({ function update(field: keyof CaseFormData, value: string) { setForm((prev) => ({ ...prev, [field]: value })); + if (errors[field]) { + setErrors((prev) => ({ ...prev, [field]: undefined })); + } } 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"; + "w-full rounded-md border border-neutral-200 bg-white px-3 py-1.5 text-sm outline-none transition-colors 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} + className={`${inputClass} ${errors.case_number ? "border-red-300 focus:border-red-400 focus:ring-red-400" : ""}`} /> + {errors.case_number && ( +

{errors.case_number}

+ )}
@@ -99,15 +119,17 @@ export function CaseForm({ update("title", e.target.value)} placeholder="Bezeichnung der Akte" - className={inputClass} + className={`${inputClass} ${errors.title ? "border-red-300 focus:border-red-400 focus:ring-red-400" : ""}`} /> + {errors.title && ( +

{errors.title}

+ )}
-
+
@@ -155,7 +177,7 @@ export function CaseForm({ diff --git a/frontend/src/components/cases/CaseTimeline.tsx b/frontend/src/components/cases/CaseTimeline.tsx index 6973b15..ffad562 100644 --- a/frontend/src/components/cases/CaseTimeline.tsx +++ b/frontend/src/components/cases/CaseTimeline.tsx @@ -3,6 +3,7 @@ import type { CaseEvent } from "@/lib/types"; import { format } from "date-fns"; import { de } from "date-fns/locale"; +import { Activity } from "lucide-react"; const EVENT_ICONS: Record = { case_created: "bg-emerald-500", @@ -20,9 +21,14 @@ interface CaseTimelineProps { export function CaseTimeline({ events }: CaseTimelineProps) { if (events.length === 0) { return ( -

- Keine Ereignisse vorhanden. -

+
+
+ +
+

+ Keine Ereignisse vorhanden. +

+
); } diff --git a/frontend/src/components/cases/PartyList.tsx b/frontend/src/components/cases/PartyList.tsx index fd2b9ab..3dbb25a 100644 --- a/frontend/src/components/cases/PartyList.tsx +++ b/frontend/src/components/cases/PartyList.tsx @@ -5,7 +5,7 @@ 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"; +import { Plus, Trash2, X, Users } from "lucide-react"; interface PartyListProps { caseId: string; @@ -19,13 +19,16 @@ interface PartyFormData { } const ROLE_OPTIONS = [ - "Klager", + "Kläger", "Beklagter", "Nebenintervenient", "Patentinhaber", "Streithelfer", ]; +const inputClass = + "w-full rounded-md border border-neutral-200 bg-white px-3 py-1.5 text-sm outline-none transition-colors focus:border-neutral-400 focus:ring-1 focus:ring-neutral-400"; + export function PartyList({ caseId, parties }: PartyListProps) { const queryClient = useQueryClient(); const [showForm, setShowForm] = useState(false); @@ -44,11 +47,11 @@ export function PartyList({ caseId, parties }: PartyListProps) { }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["case", caseId] }); - toast.success("Partei hinzugefugt"); + toast.success("Partei hinzugefügt"); setShowForm(false); setForm({ name: "", role: "", representative: "" }); }, - onError: () => toast.error("Fehler beim Hinzufugen"), + onError: () => toast.error("Fehler beim Hinzufügen"), }); const deleteMutation = useMutation({ @@ -60,9 +63,6 @@ export function PartyList({ caseId, parties }: PartyListProps) { 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 (
@@ -72,25 +72,37 @@ export function PartyList({ caseId, parties }: PartyListProps) { {!showForm && ( )}
{parties.length === 0 && !showForm && ( -

- Keine Parteien vorhanden. -

+
+
+ +
+

+ Keine Parteien vorhanden. +

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

@@ -105,7 +117,7 @@ export function PartyList({ caseId, parties }: PartyListProps) {

@@ -130,19 +142,22 @@ export function PartyList({ caseId, parties }: PartyListProps) { { e.preventDefault(); + if (!form.name.trim()) { + toast.error("Bitte Namen eingeben"); + return; + } addMutation.mutate(form); }} className="mt-3 space-y-3" > setForm({ ...form, name: e.target.value })} className={inputClass} /> -
+
setTriggerDate(e.target.value)} - className="w-full rounded-md border border-neutral-200 bg-white px-3 py-2 text-sm text-neutral-900" + className={inputClass} />
{/* Filters */} -
+
Filter: @@ -161,18 +181,18 @@ export function DeadlineList() { {cases && cases.length > 0 && (