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:
@@ -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 (
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<div className="animate-fade-in mx-auto max-w-4xl">
|
||||
<div className="mb-6 flex items-center gap-3">
|
||||
<Brain className="h-5 w-5 text-neutral-500" />
|
||||
<div>
|
||||
@@ -118,7 +118,7 @@ export default function AIExtractPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-neutral-200 bg-white p-6">
|
||||
<div className="rounded-lg border border-neutral-200 bg-white p-4 sm:p-6">
|
||||
<ExtractionForm
|
||||
cases={cases}
|
||||
selectedCaseId={selectedCaseId}
|
||||
@@ -129,7 +129,7 @@ export default function AIExtractPage() {
|
||||
</div>
|
||||
|
||||
{results !== null && (
|
||||
<div className="mt-6 rounded-lg border border-neutral-200 bg-white p-6">
|
||||
<div className="animate-fade-in mt-6 rounded-lg border border-neutral-200 bg-white p-4 sm:p-6">
|
||||
<ExtractionResults
|
||||
deadlines={results}
|
||||
onAdopt={handleAdopt}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -26,19 +26,19 @@ export default function NewCasePage() {
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl">
|
||||
<div className="animate-fade-in mx-auto max-w-2xl">
|
||||
<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>
|
||||
<h1 className="text-lg font-semibold text-neutral-900">Neue Akte</h1>
|
||||
<p className="mt-0.5 text-sm text-neutral-500">
|
||||
Neue Akte im System anlegen
|
||||
</p>
|
||||
<div className="mt-6 rounded-md border border-neutral-200 bg-white p-6">
|
||||
<div className="mt-6 rounded-md border border-neutral-200 bg-white p-4 sm:p-6">
|
||||
<CaseForm
|
||||
onSubmit={(data) => mutation.mutate(data)}
|
||||
isSubmitting={mutation.isPending}
|
||||
|
||||
@@ -5,13 +5,15 @@ 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 { Plus, Search, FolderOpen } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { SkeletonTable } from "@/components/ui/Skeleton";
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: "", label: "Alle Status" },
|
||||
{ value: "active", label: "Aktiv" },
|
||||
{ value: "pending", label: "Anhangig" },
|
||||
{ value: "pending", label: "Anhängig" },
|
||||
{ value: "closed", label: "Geschlossen" },
|
||||
{ value: "archived", label: "Archiviert" },
|
||||
];
|
||||
@@ -20,9 +22,9 @@ const TYPE_OPTIONS = [
|
||||
{ value: "", label: "Alle Typen" },
|
||||
{ value: "INF", label: "Verletzungsklage" },
|
||||
{ value: "REV", label: "Widerruf" },
|
||||
{ value: "CCR", label: "Einstweilige Verfugung" },
|
||||
{ value: "CCR", label: "Einstweilige Verfügung" },
|
||||
{ value: "APP", label: "Berufung" },
|
||||
{ value: "PI", label: "Vorlaufiger Rechtsschutz" },
|
||||
{ value: "PI", label: "Vorläufiger Rechtsschutz" },
|
||||
{ value: "ZPO_CIVIL", label: "ZPO Zivilverfahren" },
|
||||
];
|
||||
|
||||
@@ -33,6 +35,16 @@ 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 inputClass =
|
||||
"rounded-md border border-neutral-200 bg-white px-2.5 py-1.5 text-sm outline-none transition-colors focus:border-neutral-400 focus:ring-1 focus:ring-neutral-400";
|
||||
|
||||
export default function CasesPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
@@ -59,24 +71,24 @@ export default function CasesPage() {
|
||||
const cases = data?.cases ?? [];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="animate-fade-in">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-neutral-900">Akten</h1>
|
||||
<p className="mt-0.5 text-sm text-neutral-500">
|
||||
{data ? `${data.total} Akten` : "Laden..."}
|
||||
{data ? `${data.total} Akten` : "\u00A0"}
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/cases/new"
|
||||
className="inline-flex items-center gap-1.5 rounded-md bg-neutral-900 px-3 py-1.5 text-sm font-medium text-white hover:bg-neutral-800"
|
||||
className="inline-flex w-fit items-center gap-1.5 rounded-md bg-neutral-900 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-neutral-800"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Neue Akte
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center gap-3">
|
||||
<div className="mt-4 flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-neutral-400" />
|
||||
<input
|
||||
@@ -84,86 +96,113 @@ export default function CasesPage() {
|
||||
placeholder="Suchen nach Aktenzeichen, Titel..."
|
||||
value={search}
|
||||
onChange={(e) => 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"
|
||||
className={`w-full pl-9 pr-3 ${inputClass}`}
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value)}
|
||||
className="rounded-md border border-neutral-200 bg-white px-2.5 py-1.5 text-sm outline-none focus:border-neutral-400"
|
||||
>
|
||||
{STATUS_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value)}
|
||||
className="rounded-md border border-neutral-200 bg-white px-2.5 py-1.5 text-sm outline-none focus:border-neutral-400"
|
||||
>
|
||||
{TYPE_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="flex gap-3">
|
||||
<select
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value)}
|
||||
className={inputClass}
|
||||
>
|
||||
{STATUS_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value)}
|
||||
className={inputClass}
|
||||
>
|
||||
{TYPE_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
{isLoading ? (
|
||||
<div className="py-12 text-center text-sm text-neutral-400">
|
||||
Laden...
|
||||
</div>
|
||||
<SkeletonTable rows={5} />
|
||||
) : cases.length === 0 ? (
|
||||
<div className="py-12 text-center text-sm text-neutral-400">
|
||||
Keine Akten gefunden.
|
||||
</div>
|
||||
<EmptyState
|
||||
icon={FolderOpen}
|
||||
title="Keine Akten gefunden"
|
||||
description={
|
||||
search || status || type
|
||||
? "Versuchen Sie andere Suchkriterien."
|
||||
: "Erstellen Sie Ihre erste Akte, um loszulegen."
|
||||
}
|
||||
action={
|
||||
!search && !status && !type ? (
|
||||
<Link
|
||||
href="/cases/new"
|
||||
className="inline-flex items-center gap-1.5 rounded-md bg-neutral-900 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-neutral-800"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Neue Akte anlegen
|
||||
</Link>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-md border border-neutral-200 bg-white">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-neutral-100 text-left text-xs font-medium uppercase tracking-wider text-neutral-400">
|
||||
<th className="px-4 py-2.5">Aktenzeichen</th>
|
||||
<th className="px-4 py-2.5">Titel</th>
|
||||
<th className="px-4 py-2.5">Typ</th>
|
||||
<th className="px-4 py-2.5">Gericht</th>
|
||||
<th className="px-4 py-2.5">Status</th>
|
||||
<th className="px-4 py-2.5">Erstellt</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-neutral-100">
|
||||
{cases.map((c) => (
|
||||
<tr
|
||||
key={c.id}
|
||||
onClick={() => router.push(`/cases/${c.id}`)}
|
||||
className="cursor-pointer hover:bg-neutral-50"
|
||||
>
|
||||
<td className="px-4 py-2.5 font-medium text-neutral-900">
|
||||
{c.case_number}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-neutral-700">{c.title}</td>
|
||||
<td className="px-4 py-2.5 text-neutral-500">
|
||||
{c.case_type ?? "-"}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-neutral-500">
|
||||
{c.court ?? "-"}
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<span
|
||||
className={`inline-block rounded-full px-2 py-0.5 text-xs font-medium ${STATUS_BADGE[c.status] ?? "bg-neutral-100 text-neutral-500"}`}
|
||||
<div className="-mx-4 overflow-x-auto sm:mx-0">
|
||||
<div className="min-w-[640px] sm:min-w-0">
|
||||
<div className="overflow-hidden rounded-md border border-neutral-200 bg-white">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-neutral-100 text-left text-xs font-medium uppercase tracking-wider text-neutral-400">
|
||||
<th className="px-4 py-2.5">Aktenzeichen</th>
|
||||
<th className="px-4 py-2.5">Titel</th>
|
||||
<th className="hidden px-4 py-2.5 md:table-cell">Typ</th>
|
||||
<th className="hidden px-4 py-2.5 lg:table-cell">
|
||||
Gericht
|
||||
</th>
|
||||
<th className="px-4 py-2.5">Status</th>
|
||||
<th className="hidden px-4 py-2.5 sm:table-cell">
|
||||
Erstellt
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-neutral-100">
|
||||
{cases.map((c) => (
|
||||
<tr
|
||||
key={c.id}
|
||||
onClick={() => router.push(`/cases/${c.id}`)}
|
||||
className="cursor-pointer transition-colors hover:bg-neutral-50"
|
||||
>
|
||||
{c.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-neutral-400">
|
||||
{new Date(c.created_at).toLocaleDateString("de-DE")}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<td className="whitespace-nowrap px-4 py-2.5 font-medium text-neutral-900">
|
||||
{c.case_number}
|
||||
</td>
|
||||
<td className="max-w-[200px] truncate px-4 py-2.5 text-neutral-700">
|
||||
{c.title}
|
||||
</td>
|
||||
<td className="hidden px-4 py-2.5 text-neutral-500 md:table-cell">
|
||||
{c.case_type ?? "-"}
|
||||
</td>
|
||||
<td className="hidden px-4 py-2.5 text-neutral-500 lg:table-cell">
|
||||
{c.court ?? "-"}
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<span
|
||||
className={`inline-block rounded-full px-2 py-0.5 text-xs font-medium ${STATUS_BADGE[c.status] ?? "bg-neutral-100 text-neutral-500"}`}
|
||||
>
|
||||
{STATUS_LABEL[c.status] ?? c.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="hidden whitespace-nowrap px-4 py-2.5 text-neutral-400 sm:table-cell">
|
||||
{new Date(c.created_at).toLocaleDateString("de-DE")}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -8,35 +8,71 @@ import { CaseOverviewGrid } from "@/components/dashboard/CaseOverviewGrid";
|
||||
import { UpcomingTimeline } from "@/components/dashboard/UpcomingTimeline";
|
||||
import { AISummaryCard } from "@/components/dashboard/AISummaryCard";
|
||||
import { QuickActions } from "@/components/dashboard/QuickActions";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { Skeleton, SkeletonCard } from "@/components/ui/Skeleton";
|
||||
import { AlertTriangle, RefreshCw } from "lucide-react";
|
||||
|
||||
function DashboardSkeleton() {
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl space-y-6">
|
||||
<div>
|
||||
<Skeleton className="h-5 w-28" />
|
||||
<Skeleton className="mt-2 h-3.5 w-52" />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Skeleton key={i} className="h-28 rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
<div className="lg:col-span-2">
|
||||
<SkeletonCard className="min-h-[200px]" />
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<SkeletonCard />
|
||||
<SkeletonCard />
|
||||
<SkeletonCard />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { data, isLoading, error } = useQuery({
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ["dashboard"],
|
||||
queryFn: () => api.get<DashboardData>("/dashboard"),
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-neutral-400" />
|
||||
</div>
|
||||
);
|
||||
return <DashboardSkeleton />;
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-sm text-neutral-500">
|
||||
Dashboard konnte nicht geladen werden.
|
||||
<div className="mx-auto max-w-md py-16 text-center">
|
||||
<div className="mx-auto mb-3 rounded-xl bg-red-50 p-3 w-fit">
|
||||
<AlertTriangle className="h-6 w-6 text-red-500" />
|
||||
</div>
|
||||
<h2 className="text-sm font-medium text-neutral-900">
|
||||
Dashboard konnte nicht geladen werden
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-neutral-500">
|
||||
Bitte versuchen Sie es erneut oder prüfen Sie Ihre Verbindung.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
className="mt-4 inline-flex items-center gap-1.5 rounded-md bg-neutral-900 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-neutral-800"
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
Erneut laden
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl space-y-6">
|
||||
<div className="animate-fade-in mx-auto max-w-6xl space-y-6">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-neutral-900">Dashboard</h1>
|
||||
<p className="mt-0.5 text-sm text-neutral-500">
|
||||
@@ -44,20 +80,15 @@ export default function DashboardPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Traffic Lights — the hero section */}
|
||||
<DeadlineTrafficLights data={data.deadline_summary} />
|
||||
|
||||
{/* Main content grid */}
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
{/* Left column: Timeline (takes 2 cols) */}
|
||||
<div className="lg:col-span-2">
|
||||
<UpcomingTimeline
|
||||
deadlines={data.upcoming_deadlines}
|
||||
appointments={data.upcoming_appointments}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right column: Case overview, AI summary, Quick actions */}
|
||||
<div className="space-y-6">
|
||||
<CaseOverviewGrid data={data.case_summary} />
|
||||
<AISummaryCard data={data} />
|
||||
|
||||
@@ -20,12 +20,12 @@ export default function FristenPage() {
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="animate-fade-in space-y-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-neutral-900">Fristen</h1>
|
||||
<p className="mt-0.5 text-sm text-neutral-500">
|
||||
Alle Fristen im Uberblick
|
||||
Alle Fristen im Überblick
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -6,18 +6,20 @@ import Link from "next/link";
|
||||
|
||||
export default function FristenrechnerPage() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="animate-fade-in space-y-4">
|
||||
<div>
|
||||
<Link
|
||||
href="/fristen"
|
||||
className="mb-2 inline-flex items-center gap-1 text-sm text-neutral-500 hover:text-neutral-700"
|
||||
className="mb-2 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 Fristen
|
||||
Zurück zu Fristen
|
||||
</Link>
|
||||
<h1 className="text-lg font-semibold text-neutral-900">Fristenrechner</h1>
|
||||
<h1 className="text-lg font-semibold text-neutral-900">
|
||||
Fristenrechner
|
||||
</h1>
|
||||
<p className="mt-0.5 text-sm text-neutral-500">
|
||||
Berechnen Sie Fristen basierend auf Verfahrensart und Auslosedatum
|
||||
Berechnen Sie Fristen basierend auf Verfahrensart und Auslösedatum
|
||||
</p>
|
||||
</div>
|
||||
<DeadlineCalculator />
|
||||
|
||||
@@ -13,7 +13,7 @@ export default function AppLayout({
|
||||
<Sidebar />
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<Header />
|
||||
<main className="flex-1 overflow-y-auto p-6">{children}</main>
|
||||
<main className="flex-1 overflow-y-auto p-4 sm:p-6">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user