feat: UI polish — responsive, loading/empty/error states, German fixes (Phase 3Q)

- Responsive sidebar: collapses on mobile with hamburger menu, slide-in animation
- Skeleton loaders: dashboard cards, case table, case detail page
- Empty states: friendly messages with icons for cases, deadlines, parties, documents
- Error states: retry button on dashboard, proper error message on case not found
- Form validation: inline error messages on case creation form
- German language: fix all missing umlauts (Zurück, wählen, Anhängig, Verfügung, etc.)
- Status labels: display German translations instead of raw status values
- Transitions: fade-in animations on page load, hover/transition-colors on all interactive elements
- Focus states: focus-visible ring for keyboard accessibility
- Mobile layout: stacking for filters, forms, tabs; horizontal scroll for tables
- Extraction results: card layout on mobile, table on desktop
- Missing types: add DashboardData, DeadlineSummary, CaseSummary, ExtractedDeadline etc.
- Fix QuickActions links to use correct routes (/cases/new, /ai/extract)
- Consistent input focus styles across all forms
This commit is contained in:
m
2026-03-25 14:16:30 +01:00
parent 2cf01073a3
commit f81a2492c6
23 changed files with 834 additions and 288 deletions

View File

@@ -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<Partial<Record<keyof CaseFormData, string>>>({});
function validate(): boolean {
const newErrors: Partial<Record<keyof CaseFormData, string>> = {};
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 (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label className="mb-1 block text-sm font-medium text-neutral-700">
Aktenzeichen *
</label>
<input
type="text"
required
value={form.case_number}
onChange={(e) => 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 && (
<p className="mt-1 text-xs text-red-600">{errors.case_number}</p>
)}
</div>
<div>
<label className="mb-1 block text-sm font-medium text-neutral-700">
@@ -87,7 +107,7 @@ export function CaseForm({
className={inputClass}
>
<option value="active">Aktiv</option>
<option value="pending">Anhangig</option>
<option value="pending">Anhängig</option>
<option value="closed">Geschlossen</option>
</select>
</div>
@@ -99,15 +119,17 @@ export function CaseForm({
</label>
<input
type="text"
required
value={form.title}
onChange={(e) => 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 && (
<p className="mt-1 text-xs text-red-600">{errors.title}</p>
)}
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label className="mb-1 block text-sm font-medium text-neutral-700">
Verfahrensart
@@ -132,7 +154,7 @@ export function CaseForm({
type="text"
value={form.court}
onChange={(e) => update("court", e.target.value)}
placeholder="z.B. UPC Munich Central Division"
placeholder="z.B. UPC München Zentralkammer"
className={inputClass}
/>
</div>
@@ -155,7 +177,7 @@ export function CaseForm({
<button
type="submit"
disabled={isSubmitting}
className="rounded-md bg-neutral-900 px-4 py-1.5 text-sm font-medium text-white hover:bg-neutral-800 disabled:opacity-50"
className="rounded-md bg-neutral-900 px-4 py-1.5 text-sm font-medium text-white transition-colors hover:bg-neutral-800 disabled:opacity-50"
>
{isSubmitting ? "Speichern..." : submitLabel}
</button>

View File

@@ -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<string, string> = {
case_created: "bg-emerald-500",
@@ -20,9 +21,14 @@ interface CaseTimelineProps {
export function CaseTimeline({ events }: CaseTimelineProps) {
if (events.length === 0) {
return (
<p className="py-8 text-center text-sm text-neutral-400">
Keine Ereignisse vorhanden.
</p>
<div className="flex flex-col items-center py-8 text-center">
<div className="rounded-xl bg-neutral-100 p-3">
<Activity className="h-5 w-5 text-neutral-400" />
</div>
<p className="mt-2 text-sm text-neutral-500">
Keine Ereignisse vorhanden.
</p>
</div>
);
}

View File

@@ -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 (
<div>
<div className="flex items-center justify-between">
@@ -72,25 +72,37 @@ export function PartyList({ caseId, parties }: PartyListProps) {
{!showForm && (
<button
onClick={() => setShowForm(true)}
className="inline-flex items-center gap-1 text-sm text-neutral-500 hover:text-neutral-700"
className="inline-flex items-center gap-1 text-sm text-neutral-500 transition-colors hover:text-neutral-700"
>
<Plus className="h-3.5 w-3.5" />
Hinzufugen
Hinzufügen
</button>
)}
</div>
{parties.length === 0 && !showForm && (
<p className="mt-4 py-4 text-center text-sm text-neutral-400">
Keine Parteien vorhanden.
</p>
<div className="mt-4 flex flex-col items-center py-6 text-center">
<div className="rounded-xl bg-neutral-100 p-3">
<Users className="h-5 w-5 text-neutral-400" />
</div>
<p className="mt-2 text-sm text-neutral-500">
Keine Parteien vorhanden.
</p>
<button
onClick={() => setShowForm(true)}
className="mt-3 inline-flex items-center gap-1 text-sm text-neutral-500 transition-colors hover:text-neutral-700"
>
<Plus className="h-3.5 w-3.5" />
Erste Partei hinzufügen
</button>
</div>
)}
<div className="mt-3 space-y-2">
{parties.map((party) => (
<div
key={party.id}
className="flex items-center justify-between rounded-md border border-neutral-200 bg-white px-4 py-2.5"
className="flex items-center justify-between rounded-md border border-neutral-200 bg-white px-4 py-2.5 transition-colors hover:bg-neutral-50"
>
<div>
<p className="text-sm font-medium text-neutral-900">
@@ -105,7 +117,7 @@ export function PartyList({ caseId, parties }: PartyListProps) {
</div>
<button
onClick={() => deleteMutation.mutate(party.id)}
className="rounded p-1 text-neutral-300 hover:bg-neutral-100 hover:text-red-500"
className="rounded p-1 text-neutral-300 transition-colors hover:bg-neutral-100 hover:text-red-500"
title="Partei entfernen"
>
<Trash2 className="h-3.5 w-3.5" />
@@ -122,7 +134,7 @@ export function PartyList({ caseId, parties }: PartyListProps) {
</span>
<button
onClick={() => setShowForm(false)}
className="text-neutral-400 hover:text-neutral-600"
className="text-neutral-400 transition-colors hover:text-neutral-600"
>
<X className="h-4 w-4" />
</button>
@@ -130,19 +142,22 @@ export function PartyList({ caseId, parties }: PartyListProps) {
<form
onSubmit={(e) => {
e.preventDefault();
if (!form.name.trim()) {
toast.error("Bitte Namen eingeben");
return;
}
addMutation.mutate(form);
}}
className="mt-3 space-y-3"
>
<input
type="text"
required
placeholder="Name der Partei"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
className={inputClass}
/>
<div className="grid grid-cols-2 gap-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<select
value={form.role}
onChange={(e) => setForm({ ...form, role: e.target.value })}
@@ -169,9 +184,9 @@ export function PartyList({ caseId, parties }: PartyListProps) {
<button
type="submit"
disabled={addMutation.isPending}
className="rounded-md bg-neutral-900 px-3 py-1.5 text-sm font-medium text-white hover:bg-neutral-800 disabled:opacity-50"
className="rounded-md bg-neutral-900 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-neutral-800 disabled:opacity-50"
>
{addMutation.isPending ? "..." : "Hinzufugen"}
{addMutation.isPending ? "Speichern..." : "Hinzufügen"}
</button>
</div>
</form>