Compare commits

...

8 Commits

Author SHA1 Message Date
m
0ab2e8b383 feat: add document management frontend (Phase 2N)
- DocumentUpload: dropzone with multi-file support, upload via
  POST /api/cases/{id}/documents, progress feedback with toast
- DocumentList: type badges, file size, upload date, download links,
  delete with inline confirmation
- Integrated as Dokumente tab in case detail page with count badge
- Eagerly fetches document count for tab badge display
2026-03-25 13:59:48 +01:00
m
2cf01073a3 feat: add AI extraction frontend page (Phase 2M) 2026-03-25 13:54:49 +01:00
m
ed83d23d06 feat: add deadline management frontend (Phase 1G) 2026-03-25 13:54:35 +01:00
m
97ebeafcf7 feat: add case list, detail, and creation pages (Phase 1F) 2026-03-25 13:54:23 +01:00
m
26887248e1 feat: add dashboard with traffic lights, timeline, AI summary (Phase 2L) 2026-03-25 13:54:13 +01:00
m
3a56d4cf11 feat: add frontend case list, detail, and creation pages (Phase 1F)
- Case list page (/cases) with search, status/type filters, status badges
- Case creation page (/cases/new) with reusable CaseForm component
- Case detail page (/cases/[id]) with tabs: Timeline, Deadlines, Documents, Parties
- CaseTimeline component for chronological case_events display
- PartyList component with inline party CRUD (add/delete)
- Updated sidebar navigation to route to /cases
2026-03-25 13:50:20 +01:00
m
45188ff5cb feat: add frontend dashboard with traffic lights, timeline, and AI summary (Phase 2L)
Dashboard page at /dashboard with 5 components:
- DeadlineTrafficLights: RED/AMBER/GREEN cards with animated counts and pulse for overdue
- CaseOverviewGrid: active/new/closed case counts
- UpcomingTimeline: merged deadlines + appointments for next 7 days, grouped by day
- AISummaryCard: natural language summary generated from dashboard data
- QuickActions: shortcuts to create cases, deadlines, AI analysis, CalDAV sync

3-column responsive grid layout. Root / redirects to /dashboard.
Fetches from GET /api/dashboard with 60s auto-refresh via react-query.
2026-03-25 13:49:24 +01:00
m
65b70975eb feat: add AI deadline extraction frontend page (Phase 2M)
- ExtractionForm: PDF dropzone (react-dropzone) + text textarea + case selector
- ExtractionResults: review table with edit/remove per row, confidence color-coding
- Page at /ai/extract: upload -> analyze -> review -> adopt deadlines to case
- Extended API client with postFormData for multipart uploads
- Added ExtractedDeadline and ExtractionResponse types
2026-03-25 13:48:53 +01:00
23 changed files with 2252 additions and 15 deletions

View File

@@ -13,6 +13,7 @@
"next": "15.5.14",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-dropzone": "^15.0.0",
"sonner": "^2.0.7",
},
"devDependencies": {
@@ -315,6 +316,8 @@
"async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="],
"attr-accept": ["attr-accept@2.2.5", "", {}, "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ=="],
"available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="],
"axe-core": ["axe-core@4.11.1", "", {}, "sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A=="],
@@ -445,6 +448,8 @@
"file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
"file-selector": ["file-selector@2.1.2", "", { "dependencies": { "tslib": "^2.7.0" } }, "sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig=="],
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
"find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="],
@@ -695,6 +700,8 @@
"react-dom": ["react-dom@19.1.0", "", { "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.0" } }, "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g=="],
"react-dropzone": ["react-dropzone@15.0.0", "", { "dependencies": { "attr-accept": "^2.2.4", "file-selector": "^2.1.0", "prop-types": "^15.8.1" }, "peerDependencies": { "react": ">= 16.8 || 18.0.0" } }, "sha512-lGjYV/EoqEjEWPnmiSvH4v5IoIAwQM2W4Z1C0Q/Pw2xD0eVzKPS359BQTUMum+1fa0kH2nrKjuavmTPOGhpLPg=="],
"react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="],

View File

@@ -17,6 +17,7 @@
"next": "15.5.14",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-dropzone": "^15.0.0",
"sonner": "^2.0.7"
},
"devDependencies": {

View File

@@ -0,0 +1,142 @@
"use client";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { Brain } from "lucide-react";
import { api } from "@/lib/api";
import type {
Case,
ExtractedDeadline,
ExtractionResponse,
PaginatedResponse,
} from "@/lib/types";
import { ExtractionForm } from "@/components/ai/ExtractionForm";
import { ExtractionResults } from "@/components/ai/ExtractionResults";
export default function AIExtractPage() {
const router = useRouter();
const [selectedCaseId, setSelectedCaseId] = useState("");
const [isExtracting, setIsExtracting] = useState(false);
const [isAdopting, setIsAdopting] = useState(false);
const [results, setResults] = useState<ExtractedDeadline[] | null>(null);
const { data: casesData } = useQuery({
queryKey: ["cases"],
queryFn: () => api.get<PaginatedResponse<Case>>("/api/cases"),
});
const cases = casesData?.data ?? [];
async function handleExtract(file: File | null, text: string) {
setIsExtracting(true);
setResults(null);
try {
let response: ExtractionResponse;
if (file) {
const formData = new FormData();
formData.append("file", file);
response = await api.postFormData<ExtractionResponse>(
"/api/ai/extract-deadlines",
formData,
);
} else {
response = await api.post<ExtractionResponse>(
"/api/ai/extract-deadlines",
{ text },
);
}
setResults(response.deadlines);
if (response.count === 0) {
toast.info("Keine Fristen im Dokument gefunden.");
} else {
toast.success(`${response.count} Frist(en) erkannt.`);
}
} catch (err: unknown) {
const message =
err && typeof err === "object" && "error" in err
? (err as { error: string }).error
: "Analyse fehlgeschlagen";
toast.error(message);
} finally {
setIsExtracting(false);
}
}
async function handleAdopt(deadlines: ExtractedDeadline[]) {
if (!selectedCaseId) return;
setIsAdopting(true);
try {
const promises = deadlines.map((d) =>
api.post(`/api/cases/${selectedCaseId}/deadlines`, {
title: d.title,
due_date: d.due_date ?? "",
source: "ai_extraction",
notes: [
d.rule_reference ? `Rechtsgrundlage: ${d.rule_reference}` : "",
d.source_quote ? `Quelle: "${d.source_quote}"` : "",
`Konfidenz: ${Math.round(d.confidence * 100)}%`,
]
.filter(Boolean)
.join("\n"),
}),
);
await Promise.all(promises);
toast.success(
`${deadlines.length} Frist(en) erfolgreich uebernommen.`,
);
router.push(`/akten/${selectedCaseId}`);
} catch (err: unknown) {
const message =
err && typeof err === "object" && "error" in err
? (err as { error: string }).error
: "Uebernahme fehlgeschlagen";
toast.error(message);
} finally {
setIsAdopting(false);
}
}
return (
<div className="mx-auto max-w-4xl">
<div className="mb-6 flex items-center gap-3">
<Brain className="h-5 w-5 text-neutral-500" />
<div>
<h1 className="text-lg font-semibold text-neutral-900">
AI Fristenanalyse
</h1>
<p className="text-sm text-neutral-500">
Fristen automatisch aus Dokumenten extrahieren
</p>
</div>
</div>
<div className="rounded-lg border border-neutral-200 bg-white p-6">
<ExtractionForm
cases={cases}
selectedCaseId={selectedCaseId}
onCaseChange={setSelectedCaseId}
onExtract={handleExtract}
isLoading={isExtracting}
/>
</div>
{results !== null && (
<div className="mt-6 rounded-lg border border-neutral-200 bg-white p-6">
<ExtractionResults
deadlines={results}
onAdopt={handleAdopt}
isAdopting={isAdopting}
/>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,236 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useParams } from "next/navigation";
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 { DocumentUpload } from "@/components/documents/DocumentUpload";
import { DocumentList } from "@/components/documents/DocumentList";
import { ArrowLeft, Clock, FileText, Users, Activity } from "lucide-react";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import Link from "next/link";
import { useState } from "react";
interface CaseDetail extends Case {
parties: Party[];
recent_events: CaseEvent[];
deadlines_count: number;
}
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 TABS = [
{ key: "timeline", label: "Verlauf", icon: Activity },
{ key: "deadlines", label: "Fristen", icon: Clock },
{ key: "documents", label: "Dokumente", icon: FileText },
{ key: "parties", label: "Parteien", icon: Users },
] as const;
type TabKey = (typeof TABS)[number]["key"];
export default function CaseDetailPage() {
const { id } = useParams<{ id: string }>();
const [activeTab, setActiveTab] = useState<TabKey>("timeline");
const { data: caseDetail, isLoading } = useQuery({
queryKey: ["case", id],
queryFn: () => api.get<CaseDetail>(`/cases/${id}`),
});
const { data: deadlinesData } = useQuery({
queryKey: ["case-deadlines", id],
queryFn: () =>
api.get<{ deadlines: Deadline[]; total: number }>(
`/deadlines?case_id=${id}`,
),
enabled: activeTab === "deadlines",
});
const { data: documentsData } = useQuery({
queryKey: ["case-documents", id],
queryFn: () => api.get<Document[]>(`/cases/${id}/documents`),
enabled: activeTab === "documents" || activeTab === "timeline",
});
if (isLoading) {
return (
<div className="py-12 text-center text-sm text-neutral-400">
Laden...
</div>
);
}
if (!caseDetail) {
return (
<div className="py-12 text-center text-sm text-neutral-400">
Akte nicht gefunden.
</div>
);
}
const deadlines = deadlinesData?.deadlines ?? [];
const documents = documentsData ?? [];
return (
<div>
<Link
href="/cases"
className="mb-4 inline-flex items-center gap-1 text-sm text-neutral-500 hover:text-neutral-700"
>
<ArrowLeft className="h-3.5 w-3.5" />
Zuruck zu Akten
</Link>
<div className="flex items-start justify-between">
<div>
<div className="flex 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}
</span>
</div>
<div className="mt-1 flex gap-4 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>}
{caseDetail.court_ref && <span>({caseDetail.court_ref})</span>}
</div>
</div>
<div className="text-right text-xs text-neutral-400">
<p>
Erstellt:{" "}
{format(new Date(caseDetail.created_at), "d. MMM yyyy", {
locale: de,
})}
</p>
<p>
Aktualisiert:{" "}
{format(new Date(caseDetail.updated_at), "d. MMM yyyy", {
locale: de,
})}
</p>
</div>
</div>
{caseDetail.ai_summary && (
<div className="mt-4 rounded-md border border-blue-100 bg-blue-50 px-4 py-3 text-sm text-blue-800">
{caseDetail.ai_summary}
</div>
)}
<div className="mt-6 border-b border-neutral-200">
<nav className="-mb-px flex 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 ${
activeTab === tab.key
? "border-neutral-900 text-neutral-900"
: "border-transparent text-neutral-400 hover:text-neutral-600"
}`}
>
<tab.icon className="h-4 w-4" />
{tab.label}
{tab.key === "deadlines" && caseDetail.deadlines_count > 0 && (
<span className="ml-1 rounded-full bg-neutral-100 px-1.5 py-0.5 text-xs text-neutral-500">
{caseDetail.deadlines_count}
</span>
)}
{tab.key === "documents" && documents.length > 0 && (
<span className="ml-1 rounded-full bg-neutral-100 px-1.5 py-0.5 text-xs text-neutral-500">
{documents.length}
</span>
)}
{tab.key === "parties" && caseDetail.parties.length > 0 && (
<span className="ml-1 rounded-full bg-neutral-100 px-1.5 py-0.5 text-xs text-neutral-500">
{caseDetail.parties.length}
</span>
)}
</button>
))}
</nav>
</div>
<div className="mt-6">
{activeTab === "timeline" && (
<CaseTimeline events={caseDetail.recent_events ?? []} />
)}
{activeTab === "deadlines" && (
<DeadlinesList deadlines={deadlines} />
)}
{activeTab === "documents" && (
<div className="space-y-6">
<DocumentUpload caseId={id} />
<DocumentList documents={documents} caseId={id} />
</div>
)}
{activeTab === "parties" && (
<PartyList caseId={id} parties={caseDetail.parties ?? []} />
)}
</div>
</div>
);
}
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>
);
}
const DEADLINE_STATUS: Record<string, string> = {
pending: "bg-amber-50 text-amber-700",
completed: "bg-emerald-50 text-emerald-700",
overdue: "bg-red-50 text-red-700",
};
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"
>
<div>
<p className="text-sm font-medium text-neutral-900">{d.title}</p>
{d.description && (
<p className="mt-0.5 text-sm text-neutral-500">
{d.description}
</p>
)}
</div>
<div className="flex items-center gap-3">
<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}
</span>
<span className="text-sm text-neutral-500">
{format(new Date(d.due_date), "d. MMM yyyy", { locale: de })}
</span>
</div>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,49 @@
"use client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { api } from "@/lib/api";
import type { Case } from "@/lib/types";
import { CaseForm, type CaseFormData } from "@/components/cases/CaseForm";
import { ArrowLeft } from "lucide-react";
import Link from "next/link";
export default function NewCasePage() {
const router = useRouter();
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (data: CaseFormData) => api.post<Case>("/cases", data),
onSuccess: (created) => {
queryClient.invalidateQueries({ queryKey: ["cases"] });
toast.success("Akte angelegt");
router.push(`/cases/${created.id}`);
},
onError: () => {
toast.error("Fehler beim Anlegen der Akte");
},
});
return (
<div className="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"
>
<ArrowLeft className="h-3.5 w-3.5" />
Zuruck 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">
<CaseForm
onSubmit={(data) => mutation.mutate(data)}
isSubmitting={mutation.isPending}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,172 @@
"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 { Plus, Search } from "lucide-react";
import { useState } from "react";
const STATUS_OPTIONS = [
{ value: "", label: "Alle Status" },
{ value: "active", label: "Aktiv" },
{ value: "pending", label: "Anhangig" },
{ 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 Verfugung" },
{ value: "APP", label: "Berufung" },
{ value: "PI", label: "Vorlaufiger 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",
};
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 = data?.cases ?? [];
return (
<div>
<div className="flex items-center 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..."}
</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"
>
<Plus className="h-4 w-4" />
Neue Akte
</Link>
</div>
<div className="mt-4 flex items-center gap-3">
<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 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"
/>
</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>
<div className="mt-4">
{isLoading ? (
<div className="py-12 text-center text-sm text-neutral-400">
Laden...
</div>
) : cases.length === 0 ? (
<div className="py-12 text-center text-sm text-neutral-400">
Keine Akten gefunden.
</div>
) : (
<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"}`}
>
{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>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,69 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api";
import type { DashboardData } from "@/lib/types";
import { DeadlineTrafficLights } from "@/components/dashboard/DeadlineTrafficLights";
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";
export default function DashboardPage() {
const { data, isLoading, error } = 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>
);
}
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.
</p>
</div>
);
}
return (
<div className="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">
Fristenübersicht und Kanzlei-Status
</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} />
<QuickActions />
</div>
</div>
</div>
);
}

View File

@@ -1,10 +1,5 @@
export default function DashboardPage() {
return (
<div>
<h1 className="text-lg font-semibold text-neutral-900">Dashboard</h1>
<p className="mt-1 text-sm text-neutral-500">
Willkommen bei KanzlAI
</p>
</div>
);
import { redirect } from "next/navigation";
export default function RootPage() {
redirect("/dashboard");
}

View File

@@ -9,3 +9,18 @@ body {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
@keyframes count-up {
0% {
transform: translateY(8px);
opacity: 0;
}
100% {
transform: translateY(0);
opacity: 1;
}
}
.animate-count-up {
animation: count-up 0.3s ease-out;
}

View File

@@ -0,0 +1,169 @@
"use client";
import { useCallback, useState } from "react";
import { useDropzone } from "react-dropzone";
import { Upload, FileText, X, Loader2 } from "lucide-react";
import type { Case } from "@/lib/types";
interface ExtractionFormProps {
cases: Case[];
selectedCaseId: string;
onCaseChange: (caseId: string) => void;
onExtract: (file: File | null, text: string) => void;
isLoading: boolean;
}
export function ExtractionForm({
cases,
selectedCaseId,
onCaseChange,
onExtract,
isLoading,
}: ExtractionFormProps) {
const [file, setFile] = useState<File | null>(null);
const [text, setText] = useState("");
const onDrop = useCallback((acceptedFiles: File[]) => {
if (acceptedFiles.length > 0) {
setFile(acceptedFiles[0]);
setText("");
}
}, []);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: { "application/pdf": [".pdf"] },
maxFiles: 1,
disabled: isLoading,
});
function removeFile() {
setFile(null);
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!selectedCaseId || (!file && !text.trim())) return;
onExtract(file, text.trim());
}
const hasInput = file !== null || text.trim().length > 0;
return (
<form onSubmit={handleSubmit} className="space-y-5">
{/* Case selector */}
<div>
<label
htmlFor="case-select"
className="mb-1.5 block text-sm font-medium text-neutral-700"
>
Akte
</label>
<select
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"
disabled={isLoading}
>
<option value="">Akte auswaehlen...</option>
{cases.map((c) => (
<option key={c.id} value={c.id}>
{c.case_number} - {c.title}
</option>
))}
</select>
</div>
{/* PDF dropzone */}
<div>
<label className="mb-1.5 block text-sm font-medium text-neutral-700">
PDF hochladen
</label>
{file ? (
<div className="flex items-center gap-3 rounded-md border border-neutral-200 bg-neutral-50 px-4 py-3">
<FileText className="h-5 w-5 shrink-0 text-neutral-500" />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-neutral-900">
{file.name}
</p>
<p className="text-xs text-neutral-500">
{(file.size / 1024).toFixed(0)} KB
</p>
</div>
<button
type="button"
onClick={removeFile}
disabled={isLoading}
className="rounded p-1 text-neutral-400 hover:bg-neutral-200 hover:text-neutral-600"
>
<X className="h-4 w-4" />
</button>
</div>
) : (
<div
{...getRootProps()}
className={`cursor-pointer rounded-md border-2 border-dashed px-6 py-8 text-center transition-colors ${
isDragActive
? "border-neutral-500 bg-neutral-50"
: "border-neutral-300 hover:border-neutral-400"
} ${isLoading ? "pointer-events-none opacity-50" : ""}`}
>
<input {...getInputProps()} />
<Upload className="mx-auto h-8 w-8 text-neutral-400" />
<p className="mt-2 text-sm text-neutral-600">
PDF hierher ziehen oder{" "}
<span className="font-medium text-neutral-900">durchsuchen</span>
</p>
<p className="mt-1 text-xs text-neutral-400">Nur PDF-Dateien</p>
</div>
)}
</div>
{/* Divider */}
<div className="flex items-center gap-3">
<div className="h-px flex-1 bg-neutral-200" />
<span className="text-xs text-neutral-400">oder</span>
<div className="h-px flex-1 bg-neutral-200" />
</div>
{/* Text input */}
<div>
<label
htmlFor="text-input"
className="mb-1.5 block text-sm font-medium text-neutral-700"
>
Text eingeben
</label>
<textarea
id="text-input"
value={text}
onChange={(e) => {
setText(e.target.value);
if (e.target.value.trim()) setFile(null);
}}
placeholder="Gerichtsschriftsatz, Beschluss oder sonstigen Text hier einfuegen..."
rows={6}
disabled={isLoading}
className="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm text-neutral-900 placeholder:text-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-1 focus:ring-neutral-500 disabled:opacity-50"
/>
</div>
{/* Submit */}
<button
type="submit"
disabled={isLoading || !hasInput || !selectedCaseId}
className="inline-flex items-center gap-2 rounded-md bg-neutral-900 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-neutral-800 disabled:cursor-not-allowed disabled:opacity-50"
>
{isLoading ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Analysiere...
</>
) : (
"Analysieren"
)}
</button>
</form>
);
}

View File

@@ -0,0 +1,238 @@
"use client";
import { useState } from "react";
import { Trash2, Check, Pencil, X, Loader2 } from "lucide-react";
import type { ExtractedDeadline } from "@/lib/types";
interface ExtractionResultsProps {
deadlines: ExtractedDeadline[];
onAdopt: (deadlines: ExtractedDeadline[]) => void;
isAdopting: boolean;
}
function confidenceColor(confidence: number): string {
if (confidence >= 0.8) return "bg-green-100 text-green-800";
if (confidence >= 0.5) return "bg-yellow-100 text-yellow-800";
return "bg-red-100 text-red-800";
}
function confidenceLabel(confidence: number): string {
if (confidence >= 0.8) return "Hoch";
if (confidence >= 0.5) return "Mittel";
return "Niedrig";
}
export function ExtractionResults({
deadlines: initialDeadlines,
onAdopt,
isAdopting,
}: ExtractionResultsProps) {
const [deadlines, setDeadlines] = useState(initialDeadlines);
const [editingIndex, setEditingIndex] = useState<number | null>(null);
const [editForm, setEditForm] = useState<ExtractedDeadline | null>(null);
function removeDeadline(index: number) {
setDeadlines((prev) => prev.filter((_, i) => i !== index));
}
function startEdit(index: number) {
setEditingIndex(index);
setEditForm({ ...deadlines[index] });
}
function cancelEdit() {
setEditingIndex(null);
setEditForm(null);
}
function saveEdit() {
if (editingIndex === null || !editForm) return;
setDeadlines((prev) =>
prev.map((d, i) => (i === editingIndex ? editForm : d)),
);
setEditingIndex(null);
setEditForm(null);
}
if (deadlines.length === 0) {
return (
<div className="rounded-md border border-neutral-200 bg-neutral-50 p-6 text-center">
<p className="text-sm text-neutral-500">
Keine Fristen gefunden. Alle extrahierten Fristen wurden entfernt.
</p>
</div>
);
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-neutral-900">
{deadlines.length} Frist{deadlines.length !== 1 ? "en" : ""} erkannt
</h3>
<button
onClick={() => onAdopt(deadlines)}
disabled={isAdopting || deadlines.length === 0}
className="inline-flex items-center gap-2 rounded-md bg-neutral-900 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-neutral-800 disabled:cursor-not-allowed disabled:opacity-50"
>
{isAdopting ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Uebernehme...
</>
) : (
<>
<Check className="h-4 w-4" />
Fristen uebernehmen
</>
)}
</button>
</div>
<div className="overflow-hidden rounded-md border border-neutral-200">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-neutral-200 bg-neutral-50">
<th className="px-4 py-2.5 text-left font-medium text-neutral-700">
Frist
</th>
<th className="px-4 py-2.5 text-left font-medium text-neutral-700">
Faelligkeitsdatum
</th>
<th className="px-4 py-2.5 text-left font-medium text-neutral-700">
Rechtsgrundlage
</th>
<th className="px-4 py-2.5 text-left font-medium text-neutral-700">
Konfidenz
</th>
<th className="px-4 py-2.5 text-left font-medium text-neutral-700">
Quellenangabe
</th>
<th className="px-4 py-2.5 text-right font-medium text-neutral-700">
Aktionen
</th>
</tr>
</thead>
<tbody>
{deadlines.map((d, i) => (
<tr
key={i}
className="border-b border-neutral-100 last:border-b-0"
>
{editingIndex === i && editForm ? (
<>
<td className="px-4 py-2">
<input
value={editForm.title}
onChange={(e) =>
setEditForm({ ...editForm, title: e.target.value })
}
className="w-full rounded border border-neutral-300 px-2 py-1 text-sm"
/>
</td>
<td className="px-4 py-2">
<input
type="date"
value={editForm.due_date ?? ""}
onChange={(e) =>
setEditForm({
...editForm,
due_date: e.target.value || null,
})
}
className="rounded border border-neutral-300 px-2 py-1 text-sm"
/>
</td>
<td className="px-4 py-2">
<input
value={editForm.rule_reference}
onChange={(e) =>
setEditForm({
...editForm,
rule_reference: e.target.value,
})
}
className="w-full rounded border border-neutral-300 px-2 py-1 text-sm"
/>
</td>
<td className="px-4 py-2">
<span
className={`inline-block rounded-full px-2 py-0.5 text-xs font-medium ${confidenceColor(editForm.confidence)}`}
>
{confidenceLabel(editForm.confidence)}
</span>
</td>
<td className="px-4 py-2 text-xs text-neutral-500">
{editForm.source_quote}
</td>
<td className="px-4 py-2 text-right">
<div className="flex items-center justify-end gap-1">
<button
onClick={saveEdit}
className="rounded p-1 text-green-600 hover:bg-green-50"
title="Speichern"
>
<Check className="h-4 w-4" />
</button>
<button
onClick={cancelEdit}
className="rounded p-1 text-neutral-400 hover:bg-neutral-100"
title="Abbrechen"
>
<X className="h-4 w-4" />
</button>
</div>
</td>
</>
) : (
<>
<td className="px-4 py-2.5 font-medium text-neutral-900">
{d.title}
</td>
<td className="px-4 py-2.5 text-neutral-700">
{d.due_date
? new Date(d.due_date).toLocaleDateString("de-DE")
: `${d.duration_value} ${d.duration_unit}`}
</td>
<td className="px-4 py-2.5 text-neutral-600">
{d.rule_reference || "-"}
</td>
<td className="px-4 py-2.5">
<span
className={`inline-block rounded-full px-2 py-0.5 text-xs font-medium ${confidenceColor(d.confidence)}`}
>
{confidenceLabel(d.confidence)}{" "}
{Math.round(d.confidence * 100)}%
</span>
</td>
<td className="max-w-48 truncate px-4 py-2.5 text-xs text-neutral-500">
{d.source_quote || "-"}
</td>
<td className="px-4 py-2.5 text-right">
<div className="flex items-center justify-end gap-1">
<button
onClick={() => startEdit(i)}
className="rounded p-1 text-neutral-400 hover:bg-neutral-100 hover:text-neutral-600"
title="Bearbeiten"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
onClick={() => removeDeadline(i)}
className="rounded p-1 text-neutral-400 hover:bg-red-50 hover:text-red-600"
title="Entfernen"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</td>
</>
)}
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -0,0 +1,165 @@
"use client";
import { useState } from "react";
const TYPE_OPTIONS = [
{ value: "", label: "-- Typ wahlen --" },
{ value: "INF", label: "Verletzungsklage (INF)" },
{ value: "REV", label: "Widerruf (REV)" },
{ value: "CCR", label: "Einstweilige Verfugung (CCR)" },
{ value: "APP", label: "Berufung (APP)" },
{ value: "PI", label: "Vorlaufiger Rechtsschutz (PI)" },
{ value: "ZPO_CIVIL", label: "ZPO Zivilverfahren" },
];
export interface CaseFormData {
case_number: string;
title: string;
case_type?: string;
court?: string;
court_ref?: string;
status: string;
}
interface CaseFormProps {
initialData?: Partial<CaseFormData>;
onSubmit: (data: CaseFormData) => void;
isSubmitting?: boolean;
submitLabel?: string;
}
export function CaseForm({
initialData,
onSubmit,
isSubmitting,
submitLabel = "Akte anlegen",
}: CaseFormProps) {
const [form, setForm] = useState<CaseFormData>({
case_number: initialData?.case_number ?? "",
title: initialData?.title ?? "",
case_type: initialData?.case_type ?? "",
court: initialData?.court ?? "",
court_ref: initialData?.court_ref ?? "",
status: initialData?.status ?? "active",
});
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const data: CaseFormData = {
...form,
case_type: form.case_type || undefined,
court: form.court || undefined,
court_ref: form.court_ref || undefined,
};
onSubmit(data);
}
function update(field: keyof CaseFormData, value: string) {
setForm((prev) => ({ ...prev, [field]: value }));
}
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 (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<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}
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-neutral-700">
Status
</label>
<select
value={form.status}
onChange={(e) => update("status", e.target.value)}
className={inputClass}
>
<option value="active">Aktiv</option>
<option value="pending">Anhangig</option>
<option value="closed">Geschlossen</option>
</select>
</div>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-neutral-700">
Titel *
</label>
<input
type="text"
required
value={form.title}
onChange={(e) => update("title", e.target.value)}
placeholder="Bezeichnung der Akte"
className={inputClass}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="mb-1 block text-sm font-medium text-neutral-700">
Verfahrensart
</label>
<select
value={form.case_type}
onChange={(e) => update("case_type", e.target.value)}
className={inputClass}
>
{TYPE_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-neutral-700">
Gericht
</label>
<input
type="text"
value={form.court}
onChange={(e) => update("court", e.target.value)}
placeholder="z.B. UPC Munich Central Division"
className={inputClass}
/>
</div>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-neutral-700">
Gerichtliches Aktenzeichen
</label>
<input
type="text"
value={form.court_ref}
onChange={(e) => update("court_ref", e.target.value)}
placeholder="z.B. UPC_CFI_123/2026"
className={inputClass}
/>
</div>
<div className="flex justify-end pt-2">
<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"
>
{isSubmitting ? "Speichern..." : submitLabel}
</button>
</div>
</form>
);
}

View File

@@ -0,0 +1,60 @@
"use client";
import type { CaseEvent } from "@/lib/types";
import { format } from "date-fns";
import { de } from "date-fns/locale";
const EVENT_ICONS: Record<string, string> = {
case_created: "bg-emerald-500",
status_changed: "bg-amber-500",
party_added: "bg-blue-500",
case_archived: "bg-neutral-400",
document_uploaded: "bg-violet-500",
deadline_created: "bg-red-500",
};
interface CaseTimelineProps {
events: CaseEvent[];
}
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>
);
}
return (
<div className="relative space-y-0">
{events.map((event, i) => (
<div key={event.id} className="relative flex gap-3 pb-6">
{i < events.length - 1 && (
<div className="absolute left-[7px] top-4 h-full w-px bg-neutral-200" />
)}
<div
className={`mt-1 h-[15px] w-[15px] shrink-0 rounded-full border-2 border-white ${EVENT_ICONS[event.event_type ?? ""] ?? "bg-neutral-300"}`}
/>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-neutral-900">
{event.title}
</p>
{event.description && (
<p className="mt-0.5 text-sm text-neutral-500">
{event.description}
</p>
)}
<p className="mt-1 text-xs text-neutral-400">
{format(
new Date(event.event_date ?? event.created_at),
"d. MMM yyyy, HH:mm",
{ locale: de },
)}
</p>
</div>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,182 @@
"use client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
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";
interface PartyListProps {
caseId: string;
parties: Party[];
}
interface PartyFormData {
name: string;
role: string;
representative: string;
}
const ROLE_OPTIONS = [
"Klager",
"Beklagter",
"Nebenintervenient",
"Patentinhaber",
"Streithelfer",
];
export function PartyList({ caseId, parties }: PartyListProps) {
const queryClient = useQueryClient();
const [showForm, setShowForm] = useState(false);
const [form, setForm] = useState<PartyFormData>({
name: "",
role: "",
representative: "",
});
const addMutation = useMutation({
mutationFn: (data: PartyFormData) =>
api.post<Party>(`/cases/${caseId}/parties`, {
name: data.name,
role: data.role || undefined,
representative: data.representative || undefined,
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["case", caseId] });
toast.success("Partei hinzugefugt");
setShowForm(false);
setForm({ name: "", role: "", representative: "" });
},
onError: () => toast.error("Fehler beim Hinzufugen"),
});
const deleteMutation = useMutation({
mutationFn: (partyId: string) => api.delete(`/parties/${partyId}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["case", caseId] });
toast.success("Partei entfernt");
},
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">
<h3 className="text-sm font-medium text-neutral-700">
Parteien ({parties.length})
</h3>
{!showForm && (
<button
onClick={() => setShowForm(true)}
className="inline-flex items-center gap-1 text-sm text-neutral-500 hover:text-neutral-700"
>
<Plus className="h-3.5 w-3.5" />
Hinzufugen
</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-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"
>
<div>
<p className="text-sm font-medium text-neutral-900">
{party.name}
</p>
<div className="mt-0.5 flex gap-3 text-xs text-neutral-500">
{party.role && <span>{party.role}</span>}
{party.representative && (
<span>Vertreter: {party.representative}</span>
)}
</div>
</div>
<button
onClick={() => deleteMutation.mutate(party.id)}
className="rounded p-1 text-neutral-300 hover:bg-neutral-100 hover:text-red-500"
title="Partei entfernen"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
))}
</div>
{showForm && (
<div className="mt-3 rounded-md border border-neutral-200 bg-neutral-50 p-4">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-neutral-700">
Neue Partei
</span>
<button
onClick={() => setShowForm(false)}
className="text-neutral-400 hover:text-neutral-600"
>
<X className="h-4 w-4" />
</button>
</div>
<form
onSubmit={(e) => {
e.preventDefault();
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">
<select
value={form.role}
onChange={(e) => setForm({ ...form, role: e.target.value })}
className={inputClass}
>
<option value="">-- Rolle --</option>
{ROLE_OPTIONS.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
<input
type="text"
placeholder="Vertreter / Anwalt"
value={form.representative}
onChange={(e) =>
setForm({ ...form, representative: e.target.value })
}
className={inputClass}
/>
</div>
<div className="flex justify-end">
<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"
>
{addMutation.isPending ? "..." : "Hinzufugen"}
</button>
</div>
</form>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,70 @@
"use client";
import { Sparkles } from "lucide-react";
import type { DashboardData } from "@/lib/types";
interface Props {
data: DashboardData;
}
function generateSummary(data: DashboardData): string {
const parts: string[] = [];
const { deadline_summary: ds, case_summary: cs, upcoming_deadlines: ud } = data;
// Deadline urgency
if (ds.overdue_count > 0) {
parts.push(
`${ds.overdue_count} Frist${ds.overdue_count > 1 ? "en" : ""} ${ds.overdue_count > 1 ? "sind" : "ist"} überfällig und ${ds.overdue_count > 1 ? "erfordern" : "erfordert"} sofortige Aufmerksamkeit.`,
);
}
if (ds.due_this_week > 0) {
parts.push(
`${ds.due_this_week} Frist${ds.due_this_week > 1 ? "en laufen" : " läuft"} diese Woche ab.`,
);
}
// Highlight most critical upcoming deadline
if (ud.length > 0) {
const next = ud[0];
parts.push(
`Die nächste Frist ist "${next.title}" in Akte ${next.case_number}.`,
);
}
// Case activity
if (cs.new_this_month > 0) {
parts.push(
`${cs.new_this_month} neue Akte${cs.new_this_month > 1 ? "n" : ""} diesen Monat bei ${cs.active_count} aktiven Verfahren.`,
);
} else {
parts.push(`${cs.active_count} aktive Verfahren.`);
}
// All good
if (ds.overdue_count === 0 && ds.due_this_week === 0) {
parts.unshift("Alle Fristen sind im Zeitplan.");
}
return parts.join(" ");
}
export function AISummaryCard({ data }: Props) {
const summary = generateSummary(data);
return (
<div className="rounded-xl border border-neutral-200 bg-white p-5">
<div className="flex items-center gap-2">
<div className="rounded-md bg-violet-50 p-1.5">
<Sparkles className="h-4 w-4 text-violet-500" />
</div>
<h2 className="text-sm font-semibold text-neutral-900">
KI-Zusammenfassung
</h2>
</div>
<p className="mt-3 text-sm leading-relaxed text-neutral-700">
{summary}
</p>
</div>
);
}

View File

@@ -0,0 +1,55 @@
"use client";
import { FolderOpen, FolderPlus, Archive } from "lucide-react";
import type { CaseSummary } from "@/lib/types";
interface Props {
data: CaseSummary;
}
export function CaseOverviewGrid({ data }: Props) {
const items = [
{
label: "Aktive Akten",
value: data.active_count,
icon: FolderOpen,
color: "text-blue-600",
bg: "bg-blue-50",
},
{
label: "Neu (Monat)",
value: data.new_this_month,
icon: FolderPlus,
color: "text-violet-600",
bg: "bg-violet-50",
},
{
label: "Abgeschlossen",
value: data.closed_count,
icon: Archive,
color: "text-neutral-500",
bg: "bg-neutral-50",
},
];
return (
<div className="rounded-xl border border-neutral-200 bg-white p-5">
<h2 className="text-sm font-semibold text-neutral-900">Aktenübersicht</h2>
<div className="mt-4 space-y-3">
{items.map((item) => (
<div key={item.label} className="flex items-center justify-between">
<div className="flex items-center gap-2.5">
<div className={`rounded-md p-1.5 ${item.bg}`}>
<item.icon className={`h-4 w-4 ${item.color}`} />
</div>
<span className="text-sm text-neutral-600">{item.label}</span>
</div>
<span className="text-lg font-semibold tabular-nums text-neutral-900">
{item.value}
</span>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,105 @@
"use client";
import { useEffect, useRef } from "react";
import { AlertTriangle, Clock, CheckCircle } from "lucide-react";
import type { DeadlineSummary } from "@/lib/types";
function AnimatedCount({ value }: { value: number }) {
const ref = useRef<HTMLSpanElement>(null);
const prevValue = useRef(value);
useEffect(() => {
const el = ref.current;
if (!el || prevValue.current === value) return;
el.classList.remove("animate-count-up");
void el.offsetWidth;
el.classList.add("animate-count-up");
prevValue.current = value;
}, [value]);
return (
<span ref={ref} className="inline-block tabular-nums">
{value}
</span>
);
}
interface Props {
data: DeadlineSummary;
onFilter?: (filter: "overdue" | "this_week" | "ok") => void;
}
export function DeadlineTrafficLights({ data, onFilter }: Props) {
const cards = [
{
key: "overdue" as const,
label: "Überfällig",
count: data.overdue_count,
icon: AlertTriangle,
bg: "bg-red-50",
border: "border-red-200",
iconColor: "text-red-500",
countColor: "text-red-700",
labelColor: "text-red-600",
ring: data.overdue_count > 0 ? "ring-2 ring-red-300 ring-offset-1" : "",
pulse: data.overdue_count > 0,
},
{
key: "this_week" as const,
label: "Diese Woche",
count: data.due_this_week,
icon: Clock,
bg: "bg-amber-50",
border: "border-amber-200",
iconColor: "text-amber-500",
countColor: "text-amber-700",
labelColor: "text-amber-600",
ring: "",
pulse: false,
},
{
key: "ok" as const,
label: "Im Zeitplan",
count: data.ok_count + data.due_next_week,
icon: CheckCircle,
bg: "bg-emerald-50",
border: "border-emerald-200",
iconColor: "text-emerald-500",
countColor: "text-emerald-700",
labelColor: "text-emerald-600",
ring: "",
pulse: false,
},
];
return (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
{cards.map((card) => (
<button
key={card.key}
onClick={() => onFilter?.(card.key)}
className={`group relative overflow-hidden rounded-xl border ${card.border} ${card.bg} ${card.ring} p-6 text-left transition-all hover:shadow-md active:scale-[0.98]`}
>
{card.pulse && (
<span className="absolute right-4 top-4 flex h-3 w-3">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-red-400 opacity-75" />
<span className="relative inline-flex h-3 w-3 rounded-full bg-red-500" />
</span>
)}
<div className="flex items-center gap-3">
<div className={`rounded-lg p-2 ${card.bg}`}>
<card.icon className={`h-5 w-5 ${card.iconColor}`} />
</div>
<span className={`text-sm font-medium ${card.labelColor}`}>
{card.label}
</span>
</div>
<div className={`mt-4 text-4xl font-bold tracking-tight ${card.countColor}`}>
<AnimatedCount value={card.count} />
</div>
</button>
))}
</div>
);
}

View File

@@ -0,0 +1,53 @@
"use client";
import Link from "next/link";
import { FolderPlus, Clock, Sparkles, CalendarSync } from "lucide-react";
const actions = [
{
label: "Neue Akte",
href: "/akten?new=1",
icon: FolderPlus,
color: "text-blue-600 bg-blue-50 hover:bg-blue-100",
},
{
label: "Frist eintragen",
href: "/fristen?new=1",
icon: Clock,
color: "text-amber-600 bg-amber-50 hover:bg-amber-100",
},
{
label: "AI Analyse",
href: "/ai",
icon: Sparkles,
color: "text-violet-600 bg-violet-50 hover:bg-violet-100",
},
{
label: "CalDAV Sync",
href: "/einstellungen",
icon: CalendarSync,
color: "text-emerald-600 bg-emerald-50 hover:bg-emerald-100",
},
];
export function QuickActions() {
return (
<div className="rounded-xl border border-neutral-200 bg-white p-5">
<h2 className="text-sm font-semibold text-neutral-900">
Schnellzugriff
</h2>
<div className="mt-3 grid grid-cols-2 gap-2">
{actions.map((action) => (
<Link
key={action.label}
href={action.href}
className={`flex items-center gap-2 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors ${action.color}`}
>
<action.icon className="h-4 w-4" />
{action.label}
</Link>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,134 @@
"use client";
import { format, parseISO, isToday, isTomorrow } from "date-fns";
import { de } from "date-fns/locale";
import { Clock, Calendar, MapPin } from "lucide-react";
import type { UpcomingDeadline, UpcomingAppointment } from "@/lib/types";
interface Props {
deadlines: UpcomingDeadline[];
appointments: UpcomingAppointment[];
}
type TimelineItem =
| { type: "deadline"; date: Date; data: UpcomingDeadline }
| { type: "appointment"; date: Date; data: UpcomingAppointment };
function formatDayLabel(date: Date): string {
if (isToday(date)) return "Heute";
if (isTomorrow(date)) return "Morgen";
return format(date, "EEEE, d. MMM", { locale: de });
}
export function UpcomingTimeline({ deadlines, appointments }: Props) {
const items: TimelineItem[] = [
...deadlines.map((d) => ({
type: "deadline" as const,
date: parseISO(d.due_date),
data: d,
})),
...appointments.map((a) => ({
type: "appointment" as const,
date: parseISO(a.start_at),
data: a,
})),
].sort((a, b) => a.date.getTime() - b.date.getTime());
// Group by day
const grouped = new Map<string, TimelineItem[]>();
for (const item of items) {
const key = format(item.date, "yyyy-MM-dd");
const group = grouped.get(key) ?? [];
group.push(item);
grouped.set(key, group);
}
const empty = items.length === 0;
return (
<div className="rounded-xl border border-neutral-200 bg-white p-5">
<h2 className="text-sm font-semibold text-neutral-900">
Nächste 7 Tage
</h2>
{empty ? (
<p className="mt-6 text-center text-sm text-neutral-400">
Keine anstehenden Termine oder Fristen
</p>
) : (
<div className="mt-4 space-y-5">
{Array.from(grouped.entries()).map(([dateKey, dayItems]) => (
<div key={dateKey}>
<p className="text-xs font-medium uppercase tracking-wider text-neutral-400">
{formatDayLabel(dayItems[0].date)}
</p>
<div className="mt-2 space-y-2">
{dayItems.map((item, i) => (
<TimelineEntry key={`${item.type}-${i}`} item={item} />
))}
</div>
</div>
))}
</div>
)}
</div>
);
}
function TimelineEntry({ item }: { item: TimelineItem }) {
if (item.type === "deadline") {
const d = item.data;
return (
<div className="flex items-start gap-3 rounded-lg border border-neutral-100 bg-neutral-50/50 px-3 py-2.5">
<div className="mt-0.5 rounded-md bg-amber-50 p-1">
<Clock className="h-3.5 w-3.5 text-amber-500" />
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-neutral-900">
{d.title}
</p>
<p className="mt-0.5 truncate text-xs text-neutral-500">
{d.case_number} · {d.case_title}
</p>
</div>
<span className="shrink-0 text-xs font-medium text-amber-600">
Frist
</span>
</div>
);
}
const a = item.data;
return (
<div className="flex items-start gap-3 rounded-lg border border-neutral-100 bg-neutral-50/50 px-3 py-2.5">
<div className="mt-0.5 rounded-md bg-blue-50 p-1">
<Calendar className="h-3.5 w-3.5 text-blue-500" />
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-neutral-900">
{a.title}
</p>
<div className="mt-0.5 flex items-center gap-2 text-xs text-neutral-500">
<span>{format(item.date, "HH:mm")} Uhr</span>
{a.location && (
<>
<span className="text-neutral-300">·</span>
<span className="flex items-center gap-0.5 truncate">
<MapPin className="h-3 w-3" />
{a.location}
</span>
</>
)}
{a.case_number && (
<>
<span className="text-neutral-300">·</span>
<span>{a.case_number}</span>
</>
)}
</div>
</div>
<span className="shrink-0 text-xs font-medium text-blue-600">
Termin
</span>
</div>
);
}

View File

@@ -0,0 +1,144 @@
"use client";
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { FileText, Download, Trash2, Loader2 } from "lucide-react";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import { toast } from "sonner";
import { api } from "@/lib/api";
import type { Document } from "@/lib/types";
const DOC_TYPE_BADGE: Record<string, string> = {
schriftsatz: "bg-blue-50 text-blue-700",
beschluss: "bg-violet-50 text-violet-700",
urteil: "bg-emerald-50 text-emerald-700",
gutachten: "bg-amber-50 text-amber-700",
vertrag: "bg-cyan-50 text-cyan-700",
korrespondenz: "bg-neutral-100 text-neutral-600",
};
interface DocumentListProps {
documents: Document[];
caseId: string;
}
export function DocumentList({ documents, caseId }: DocumentListProps) {
const [deleteId, setDeleteId] = useState<string | null>(null);
const queryClient = useQueryClient();
const deleteMutation = useMutation({
mutationFn: (docId: string) => api.delete(`/documents/${docId}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["case-documents", caseId] });
queryClient.invalidateQueries({ queryKey: ["case", caseId] });
toast.success("Dokument geloescht");
setDeleteId(null);
},
onError: (err) => {
const msg =
err && typeof err === "object" && "error" in err
? (err as { error: string }).error
: "Unbekannter Fehler";
toast.error(`Fehler beim Loeschen: ${msg}`);
setDeleteId(null);
},
});
if (documents.length === 0) {
return (
<p className="py-8 text-center text-sm text-neutral-400">
Keine Dokumente vorhanden.
</p>
);
}
return (
<div className="space-y-2">
{documents.map((doc) => (
<div
key={doc.id}
className="flex items-center justify-between rounded-md border border-neutral-200 bg-white px-4 py-3"
>
<div className="flex items-center gap-3 min-w-0">
<FileText className="h-4 w-4 shrink-0 text-neutral-400" />
<div className="min-w-0">
<p className="truncate text-sm font-medium text-neutral-900">
{doc.title}
</p>
<div className="mt-0.5 flex flex-wrap items-center gap-2 text-xs text-neutral-400">
{doc.doc_type && (
<span
className={`rounded-full px-2 py-0.5 text-xs font-medium ${
DOC_TYPE_BADGE[doc.doc_type.toLowerCase()] ??
"bg-neutral-100 text-neutral-600"
}`}
>
{doc.doc_type}
</span>
)}
{doc.file_size != null && (
<span>{formatFileSize(doc.file_size)}</span>
)}
<span>
{format(new Date(doc.created_at), "d. MMM yyyy", {
locale: de,
})}
</span>
</div>
</div>
</div>
<div className="flex items-center gap-1 shrink-0 ml-3">
<a
href={`/api/documents/${doc.id}`}
className="rounded p-1.5 text-neutral-400 hover:bg-neutral-100 hover:text-neutral-600"
title="Herunterladen"
>
<Download className="h-4 w-4" />
</a>
{deleteId === doc.id ? (
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => deleteMutation.mutate(doc.id)}
disabled={deleteMutation.isPending}
className="rounded px-2 py-1 text-xs font-medium text-red-600 hover:bg-red-50"
>
{deleteMutation.isPending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
"Loeschen"
)}
</button>
<button
type="button"
onClick={() => setDeleteId(null)}
className="rounded px-2 py-1 text-xs text-neutral-500 hover:bg-neutral-100"
>
Abbrechen
</button>
</div>
) : (
<button
type="button"
onClick={() => setDeleteId(doc.id)}
className="rounded p-1.5 text-neutral-400 hover:bg-neutral-100 hover:text-red-500"
title="Loeschen"
>
<Trash2 className="h-4 w-4" />
</button>
)}
</div>
</div>
))}
</div>
);
}
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}

View File

@@ -0,0 +1,144 @@
"use client";
import { useCallback, useState } from "react";
import { useDropzone } from "react-dropzone";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Upload, FileText, X, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { api } from "@/lib/api";
import type { Document } from "@/lib/types";
interface DocumentUploadProps {
caseId: string;
}
export function DocumentUpload({ caseId }: DocumentUploadProps) {
const [files, setFiles] = useState<File[]>([]);
const queryClient = useQueryClient();
const uploadMutation = useMutation({
mutationFn: async (file: File) => {
const formData = new FormData();
formData.append("file", file);
formData.append("title", file.name);
return api.postFormData<Document>(`/cases/${caseId}/documents`, formData);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["case-documents", caseId] });
queryClient.invalidateQueries({ queryKey: ["case", caseId] });
},
});
const onDrop = useCallback((acceptedFiles: File[]) => {
setFiles((prev) => [...prev, ...acceptedFiles]);
}, []);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
disabled: uploadMutation.isPending,
});
function removeFile(index: number) {
setFiles((prev) => prev.filter((_, i) => i !== index));
}
async function handleUpload() {
if (files.length === 0) return;
let successCount = 0;
for (const file of files) {
try {
await uploadMutation.mutateAsync(file);
successCount++;
} catch (err) {
const msg =
err && typeof err === "object" && "error" in err
? (err as { error: string }).error
: file.name;
toast.error(`Fehler beim Hochladen: ${msg}`);
}
}
if (successCount > 0) {
toast.success(
successCount === 1
? "Dokument hochgeladen"
: `${successCount} Dokumente hochgeladen`,
);
setFiles([]);
}
}
return (
<div className="space-y-3">
<div
{...getRootProps()}
className={`cursor-pointer rounded-md border-2 border-dashed px-6 py-6 text-center transition-colors ${
isDragActive
? "border-neutral-500 bg-neutral-50"
: "border-neutral-300 hover:border-neutral-400"
} ${uploadMutation.isPending ? "pointer-events-none opacity-50" : ""}`}
>
<input {...getInputProps()} />
<Upload className="mx-auto h-6 w-6 text-neutral-400" />
<p className="mt-2 text-sm text-neutral-600">
Dateien hierher ziehen oder{" "}
<span className="font-medium text-neutral-900">durchsuchen</span>
</p>
<p className="mt-1 text-xs text-neutral-400">Max. 50 MB pro Datei</p>
</div>
{files.length > 0 && (
<div className="space-y-2">
{files.map((file, i) => (
<div
key={`${file.name}-${i}`}
className="flex items-center gap-3 rounded-md border border-neutral-200 bg-neutral-50 px-3 py-2"
>
<FileText className="h-4 w-4 shrink-0 text-neutral-500" />
<div className="min-w-0 flex-1">
<p className="truncate text-sm text-neutral-900">{file.name}</p>
<p className="text-xs text-neutral-400">
{formatFileSize(file.size)}
</p>
</div>
<button
type="button"
onClick={() => removeFile(i)}
disabled={uploadMutation.isPending}
className="rounded p-1 text-neutral-400 hover:bg-neutral-200 hover:text-neutral-600"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
))}
<button
type="button"
onClick={handleUpload}
disabled={uploadMutation.isPending}
className="inline-flex items-center gap-2 rounded-md bg-neutral-900 px-3 py-1.5 text-sm font-medium text-white hover:bg-neutral-800 disabled:opacity-50"
>
{uploadMutation.isPending ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Hochladen...
</>
) : (
<>
<Upload className="h-3.5 w-3.5" />
{files.length === 1 ? "Hochladen" : `${files.length} Dateien hochladen`}
</>
)}
</button>
</div>
)}
</div>
);
}
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}

View File

@@ -12,11 +12,11 @@ import {
} from "lucide-react";
const navigation = [
{ name: "Dashboard", href: "/", icon: LayoutDashboard },
{ name: "Dashboard", href: "/dashboard", icon: LayoutDashboard },
{ name: "Akten", href: "/akten", icon: FolderOpen },
{ name: "Fristen", href: "/fristen", icon: Clock },
{ name: "Termine", href: "/termine", icon: Calendar },
{ name: "AI Analyse", href: "/ai", icon: Brain },
{ name: "AI Analyse", href: "/ai/extract", icon: Brain },
{ name: "Einstellungen", href: "/einstellungen", icon: Settings },
];
@@ -30,10 +30,7 @@ export function Sidebar() {
</div>
<nav className="flex-1 space-y-0.5 p-2">
{navigation.map((item) => {
const isActive =
item.href === "/"
? pathname === "/"
: pathname.startsWith(item.href);
const isActive = pathname.startsWith(item.href);
return (
<Link
key={item.href}

View File

@@ -79,6 +79,41 @@ class ApiClient {
delete<T>(path: string) {
return this.request<T>(path, { method: "DELETE" });
}
async postFormData<T>(path: string, formData: FormData): Promise<T> {
const supabase = createClient();
const {
data: { session },
} = await supabase.auth.getSession();
const headers: HeadersInit = {};
if (session?.access_token) {
headers["Authorization"] = `Bearer ${session.access_token}`;
}
const tenantId = typeof window !== "undefined"
? localStorage.getItem("kanzlai_tenant_id")
: null;
if (tenantId) {
headers["X-Tenant-ID"] = tenantId;
}
const res = await fetch(`${this.baseUrl}${path}`, {
method: "POST",
headers,
body: formData,
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
const err: ApiError = {
error: body.error || res.statusText,
status: res.status,
};
throw err;
}
return res.json();
}
}
export const api = new ApiClient();