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

@@ -6,11 +6,19 @@ 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 {
ArrowLeft,
Clock,
FileText,
Users,
Activity,
AlertTriangle,
} from "lucide-react";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import Link from "next/link";
import { useState } from "react";
import { Skeleton } from "@/components/ui/Skeleton";
interface CaseDetail extends Case {
parties: Party[];
@@ -25,6 +33,13 @@ const STATUS_BADGE: Record<string, string> = {
archived: "bg-neutral-100 text-neutral-400",
};
const STATUS_LABEL: Record<string, string> = {
active: "Aktiv",
pending: "Anhängig",
closed: "Geschlossen",
archived: "Archiviert",
};
const TABS = [
{ key: "timeline", label: "Verlauf", icon: Activity },
{ key: "deadlines", label: "Fristen", icon: Clock },
@@ -34,11 +49,43 @@ const TABS = [
type TabKey = (typeof TABS)[number]["key"];
function CaseDetailSkeleton() {
return (
<div>
<Skeleton className="h-4 w-28" />
<div className="mt-4 flex items-start justify-between">
<div>
<Skeleton className="h-6 w-48" />
<Skeleton className="mt-2 h-4 w-64" />
</div>
<div className="space-y-1">
<Skeleton className="h-3 w-24" />
<Skeleton className="h-3 w-24" />
</div>
</div>
<div className="mt-6 flex gap-4 border-b border-neutral-200 pb-2.5">
{[1, 2, 3, 4].map((i) => (
<Skeleton key={i} className="h-4 w-20" />
))}
</div>
<div className="mt-6 space-y-3">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-14 rounded-md" />
))}
</div>
</div>
);
}
export default function CaseDetailPage() {
const { id } = useParams<{ id: string }>();
const [activeTab, setActiveTab] = useState<TabKey>("timeline");
const { data: caseDetail, isLoading } = useQuery({
const {
data: caseDetail,
isLoading,
error,
} = useQuery({
queryKey: ["case", id],
queryFn: () => api.get<CaseDetail>(`/cases/${id}`),
});
@@ -59,17 +106,28 @@ export default function CaseDetailPage() {
});
if (isLoading) {
return (
<div className="py-12 text-center text-sm text-neutral-400">
Laden...
</div>
);
return <CaseDetailSkeleton />;
}
if (!caseDetail) {
if (error || !caseDetail) {
return (
<div className="py-12 text-center text-sm text-neutral-400">
Akte nicht gefunden.
<div className="py-12 text-center">
<div className="mx-auto mb-3 w-fit rounded-xl bg-red-50 p-3">
<AlertTriangle className="h-6 w-6 text-red-500" />
</div>
<p className="text-sm font-medium text-neutral-900">
Akte nicht gefunden
</p>
<p className="mt-1 text-sm text-neutral-500">
Die Akte existiert nicht oder Sie haben keine Berechtigung.
</p>
<Link
href="/cases"
className="mt-4 inline-flex items-center gap-1 text-sm text-neutral-500 transition-colors hover:text-neutral-700"
>
<ArrowLeft className="h-3.5 w-3.5" />
Zurück zu Akten
</Link>
</div>
);
}
@@ -78,28 +136,28 @@ export default function CaseDetailPage() {
const documents = documentsData ?? [];
return (
<div>
<div className="animate-fade-in">
<Link
href="/cases"
className="mb-4 inline-flex items-center gap-1 text-sm text-neutral-500 hover:text-neutral-700"
className="mb-4 inline-flex items-center gap-1 text-sm text-neutral-500 transition-colors hover:text-neutral-700"
>
<ArrowLeft className="h-3.5 w-3.5" />
Zuruck zu Akten
Zurück zu Akten
</Link>
<div className="flex items-start justify-between">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<div className="flex items-center gap-3">
<div className="flex flex-wrap items-center gap-3">
<h1 className="text-lg font-semibold text-neutral-900">
{caseDetail.title}
</h1>
<span
className={`inline-block rounded-full px-2 py-0.5 text-xs font-medium ${STATUS_BADGE[caseDetail.status] ?? "bg-neutral-100 text-neutral-500"}`}
>
{caseDetail.status}
{STATUS_LABEL[caseDetail.status] ?? caseDetail.status}
</span>
</div>
<div className="mt-1 flex gap-4 text-sm text-neutral-500">
<div className="mt-1 flex flex-wrap gap-x-4 gap-y-1 text-sm text-neutral-500">
<span>Az. {caseDetail.case_number}</span>
{caseDetail.case_type && <span>{caseDetail.case_type}</span>}
{caseDetail.court && <span>{caseDetail.court}</span>}
@@ -129,12 +187,12 @@ export default function CaseDetailPage() {
)}
<div className="mt-6 border-b border-neutral-200">
<nav className="-mb-px flex gap-4">
<nav className="-mb-px flex gap-1 overflow-x-auto sm:gap-4">
{TABS.map((tab) => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`inline-flex items-center gap-1.5 border-b-2 px-1 pb-2.5 text-sm font-medium transition-colors ${
className={`inline-flex shrink-0 items-center gap-1.5 border-b-2 px-1 pb-2.5 text-sm font-medium transition-colors ${
activeTab === tab.key
? "border-neutral-900 text-neutral-900"
: "border-transparent text-neutral-400 hover:text-neutral-600"
@@ -181,9 +239,14 @@ export default function CaseDetailPage() {
function DeadlinesList({ deadlines }: { deadlines: Deadline[] }) {
if (deadlines.length === 0) {
return (
<p className="py-8 text-center text-sm text-neutral-400">
Keine Fristen vorhanden.
</p>
<div className="flex flex-col items-center py-8 text-center">
<div className="rounded-xl bg-neutral-100 p-3">
<Clock className="h-5 w-5 text-neutral-400" />
</div>
<p className="mt-2 text-sm text-neutral-500">
Keine Fristen vorhanden.
</p>
</div>
);
}
@@ -193,12 +256,18 @@ function DeadlinesList({ deadlines }: { deadlines: Deadline[] }) {
overdue: "bg-red-50 text-red-700",
};
const DEADLINE_STATUS_LABEL: Record<string, string> = {
pending: "Offen",
completed: "Erledigt",
overdue: "Überfällig",
};
return (
<div className="space-y-2">
{deadlines.map((d) => (
<div
key={d.id}
className="flex items-center justify-between rounded-md border border-neutral-200 bg-white px-4 py-3"
className="flex flex-col gap-2 rounded-md border border-neutral-200 bg-white px-4 py-3 sm:flex-row sm:items-center sm:justify-between"
>
<div>
<p className="text-sm font-medium text-neutral-900">{d.title}</p>
@@ -212,9 +281,9 @@ function DeadlinesList({ deadlines }: { deadlines: Deadline[] }) {
<span
className={`rounded-full px-2 py-0.5 text-xs font-medium ${DEADLINE_STATUS[d.status] ?? "bg-neutral-100 text-neutral-500"}`}
>
{d.status}
{DEADLINE_STATUS_LABEL[d.status] ?? d.status}
</span>
<span className="text-sm text-neutral-500">
<span className="whitespace-nowrap text-sm text-neutral-500">
{format(new Date(d.due_date), "d. MMM yyyy", { locale: de })}
</span>
</div>
@@ -227,9 +296,14 @@ function DeadlinesList({ deadlines }: { deadlines: Deadline[] }) {
function DocumentsList({ documents }: { documents: Document[] }) {
if (documents.length === 0) {
return (
<p className="py-8 text-center text-sm text-neutral-400">
Keine Dokumente vorhanden.
</p>
<div className="flex flex-col items-center py-8 text-center">
<div className="rounded-xl bg-neutral-100 p-3">
<FileText className="h-5 w-5 text-neutral-400" />
</div>
<p className="mt-2 text-sm text-neutral-500">
Keine Dokumente vorhanden.
</p>
</div>
);
}
@@ -256,7 +330,7 @@ function DocumentsList({ documents }: { documents: Document[] }) {
</div>
<a
href={`/api/documents/${doc.id}`}
className="text-sm text-neutral-500 hover:text-neutral-700"
className="text-sm text-neutral-500 transition-colors hover:text-neutral-700"
>
Herunterladen
</a>