- Breadcrumb component: reusable nav with items array (label+href)
- DeadlineTrafficLights: buttons → Links to /fristen?status={filter}
- CaseOverviewGrid: static metrics → clickable Links to /cases?status={filter}
- UpcomingTimeline: items → clickable Links to /fristen/{id} or /termine/{id}
with case number links and hover chevron
- QuickActions: swap CalDAV Sync for "Neuer Termin" → /termine/neu,
fix "Frist eintragen" → /fristen/neu
- AISummaryCard: add RefreshCw button with spinning animation
- RecentActivityList: new component showing recent case events
- DeadlineList: accept initialStatus prop, add this_week/ok filters
- fristen/page.tsx: read searchParams.status for initial filter
- Add breadcrumbs to dashboard, fristen, cases, termine pages
- Add RecentActivity type, update DashboardData type
219 lines
8.1 KiB
TypeScript
219 lines
8.1 KiB
TypeScript
"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 { Breadcrumb } from "@/components/layout/Breadcrumb";
|
|
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: "Anhängig" },
|
|
{ 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 Verfügung" },
|
|
{ value: "APP", label: "Berufung" },
|
|
{ value: "PI", label: "Vorläufiger Rechtsschutz" },
|
|
{ value: "ZPO_CIVIL", label: "ZPO Zivilverfahren" },
|
|
];
|
|
|
|
const STATUS_BADGE: Record<string, string> = {
|
|
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 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();
|
|
|
|
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 = Array.isArray(data?.cases) ? data.cases : [];
|
|
|
|
return (
|
|
<div className="animate-fade-in">
|
|
<Breadcrumb
|
|
items={[
|
|
{ label: "Dashboard", href: "/dashboard" },
|
|
{ label: "Akten" },
|
|
]}
|
|
/>
|
|
<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` : "\u00A0"}
|
|
</p>
|
|
</div>
|
|
<Link
|
|
href="/cases/new"
|
|
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 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
|
|
type="text"
|
|
placeholder="Suchen nach Aktenzeichen, Titel..."
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
className={`w-full pl-9 pr-3 ${inputClass}`}
|
|
/>
|
|
</div>
|
|
<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 ? (
|
|
<SkeletonTable rows={5} />
|
|
) : cases.length === 0 ? (
|
|
<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="-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"
|
|
>
|
|
<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>
|
|
</div>
|
|
);
|
|
}
|