Compare commits

...

2 Commits

Author SHA1 Message Date
m
9787450d91 feat: refactor case detail from useState tabs to URL-based nested routes
Refactors the monolithic cases/[id]/page.tsx into Next.js nested routes
with a shared layout for the case header and tab navigation bar.

Route structure:
- cases/[id]/layout.tsx — case header + tab bar (active tab from URL)
- cases/[id]/page.tsx — redirects to ./verlauf
- cases/[id]/verlauf/page.tsx — timeline tab
- cases/[id]/fristen/page.tsx — deadlines tab
- cases/[id]/dokumente/page.tsx — documents tab (with upload)
- cases/[id]/parteien/page.tsx — parties tab
- cases/[id]/notizen/page.tsx — notes tab (new, uses NotesList)

New shared components:
- Breadcrumb.tsx — reusable breadcrumb navigation
- NotesList.tsx — reusable notes CRUD (inline create/edit/delete)
- Note type added to types.ts

Benefits: deep linking, browser back/forward, bookmarkable tabs.
2026-03-25 19:28:29 +01:00
m
9ad58e1ba3 docs: design document for dashboard redesign + detail pages 2026-03-25 18:51:44 +01:00
10 changed files with 704 additions and 339 deletions

View File

@@ -0,0 +1,35 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useParams } from "next/navigation";
import { api } from "@/lib/api";
import type { Document } from "@/lib/types";
import { DocumentList } from "@/components/documents/DocumentList";
import { DocumentUpload } from "@/components/documents/DocumentUpload";
import { Loader2 } from "lucide-react";
export default function DokumentePage() {
const { id } = useParams<{ id: string }>();
const { data, isLoading } = useQuery({
queryKey: ["case-documents", id],
queryFn: () => api.get<Document[]>(`/cases/${id}/documents`),
});
if (isLoading) {
return (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-neutral-400" />
</div>
);
}
const documents = Array.isArray(data) ? data : [];
return (
<div className="space-y-6">
<DocumentUpload caseId={id} />
<DocumentList documents={documents} caseId={id} />
</div>
);
}

View File

@@ -0,0 +1,86 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useParams } from "next/navigation";
import { api } from "@/lib/api";
import type { Deadline } from "@/lib/types";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import { Clock, Loader2 } from "lucide-react";
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",
};
const DEADLINE_STATUS_LABEL: Record<string, string> = {
pending: "Offen",
completed: "Erledigt",
overdue: "Ueberfaellig",
};
export default function FristenPage() {
const { id } = useParams<{ id: string }>();
const { data, isLoading } = useQuery({
queryKey: ["case-deadlines", id],
queryFn: () =>
api.get<{ deadlines: Deadline[]; total: number }>(
`/deadlines?case_id=${id}`,
),
});
if (isLoading) {
return (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-neutral-400" />
</div>
);
}
const deadlines = Array.isArray(data?.deadlines) ? data.deadlines : [];
if (deadlines.length === 0) {
return (
<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>
);
}
return (
<div className="space-y-2">
{deadlines.map((d) => (
<div
key={d.id}
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>
{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"}`}
>
{DEADLINE_STATUS_LABEL[d.status] ?? d.status}
</span>
<span className="whitespace-nowrap 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,226 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useParams, usePathname } from "next/navigation";
import Link from "next/link";
import { api } from "@/lib/api";
import type { Case } from "@/lib/types";
import { Breadcrumb } from "@/components/layout/Breadcrumb";
import { Skeleton } from "@/components/ui/Skeleton";
import {
ArrowLeft,
Activity,
Clock,
FileText,
Users,
StickyNote,
AlertTriangle,
} from "lucide-react";
import { format } from "date-fns";
import { de } from "date-fns/locale";
interface CaseDetail extends Case {
parties: unknown[];
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 STATUS_LABEL: Record<string, string> = {
active: "Aktiv",
pending: "Anhaengig",
closed: "Geschlossen",
archived: "Archiviert",
};
const TABS = [
{ segment: "verlauf", label: "Verlauf", icon: Activity },
{ segment: "fristen", label: "Fristen", icon: Clock },
{ segment: "dokumente", label: "Dokumente", icon: FileText },
{ segment: "parteien", label: "Parteien", icon: Users },
{ segment: "notizen", label: "Notizen", icon: StickyNote },
] as const;
const TAB_LABELS: Record<string, string> = {
verlauf: "Verlauf",
fristen: "Fristen",
dokumente: "Dokumente",
parteien: "Parteien",
notizen: "Notizen",
};
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, 5].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 CaseDetailLayout({
children,
}: {
children: React.ReactNode;
}) {
const { id } = useParams<{ id: string }>();
const pathname = usePathname();
const {
data: caseDetail,
isLoading,
error,
} = useQuery({
queryKey: ["case", id],
queryFn: () => api.get<CaseDetail>(`/cases/${id}`),
});
// Determine active tab from pathname
const segments = pathname.split("/");
const activeSegment = segments[segments.length - 1] || "verlauf";
const activeTabLabel = TAB_LABELS[activeSegment];
if (isLoading) {
return <CaseDetailSkeleton />;
}
if (error || !caseDetail) {
return (
<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" />
Zurueck zu Akten
</Link>
</div>
);
}
const breadcrumbItems = [
{ label: "Dashboard", href: "/dashboard" },
{ label: "Akten", href: "/cases" },
{ label: caseDetail.case_number, href: `/cases/${id}/verlauf` },
...(activeTabLabel ? [{ label: activeTabLabel }] : []),
];
const partiesCount = Array.isArray(caseDetail.parties)
? caseDetail.parties.length
: 0;
return (
<div className="animate-fade-in">
<Breadcrumb items={breadcrumbItems} />
<div className="mt-4 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<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"}`}
>
{STATUS_LABEL[caseDetail.status] ?? caseDetail.status}
</span>
</div>
<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>}
{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-1 overflow-x-auto sm:gap-4">
{TABS.map((tab) => {
const isActive = activeSegment === tab.segment;
return (
<Link
key={tab.segment}
href={`/cases/${id}/${tab.segment}`}
className={`inline-flex shrink-0 items-center gap-1.5 border-b-2 px-1 pb-2.5 text-sm font-medium transition-colors ${
isActive
? "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.segment === "fristen" &&
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.segment === "parteien" && partiesCount > 0 && (
<span className="ml-1 rounded-full bg-neutral-100 px-1.5 py-0.5 text-xs text-neutral-500">
{partiesCount}
</span>
)}
</Link>
);
})}
</nav>
</div>
<div className="mt-6">{children}</div>
</div>
);
}

View File

@@ -0,0 +1,10 @@
"use client";
import { useParams } from "next/navigation";
import { NotesList } from "@/components/notes/NotesList";
export default function NotizenPage() {
const { id } = useParams<{ id: string }>();
return <NotesList parentType="case" parentId={id} />;
}

View File

@@ -1,341 +1,10 @@
"use client";
import { redirect } from "next/navigation";
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 {
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[];
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 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 },
{ key: "documents", label: "Dokumente", icon: FileText },
{ key: "parties", label: "Parteien", icon: Users },
] as const;
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,
error,
} = 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",
});
if (isLoading) {
return <CaseDetailSkeleton />;
}
if (error || !caseDetail) {
return (
<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>
);
}
const deadlines = Array.isArray(deadlinesData?.deadlines) ? deadlinesData.deadlines : [];
const documents = Array.isArray(documentsData) ? documentsData : [];
return (
<div className="animate-fade-in">
<Link
href="/cases"
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" />
Zurück zu Akten
</Link>
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<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"}`}
>
{STATUS_LABEL[caseDetail.status] ?? caseDetail.status}
</span>
</div>
<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>}
{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-1 overflow-x-auto sm:gap-4">
{TABS.map((tab) => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
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"
}`}
>
<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 === "parties" && Array.isArray(caseDetail.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={Array.isArray(caseDetail.recent_events) ? caseDetail.recent_events : []} />
)}
{activeTab === "deadlines" && (
<DeadlinesList deadlines={deadlines} />
)}
{activeTab === "documents" && (
<DocumentsList documents={documents} />
)}
{activeTab === "parties" && (
<PartyList caseId={id} parties={Array.isArray(caseDetail.parties) ? caseDetail.parties : []} />
)}
</div>
</div>
);
}
function DeadlinesList({ deadlines }: { deadlines: Deadline[] }) {
if (deadlines.length === 0) {
return (
<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>
);
}
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",
};
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 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>
{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"}`}
>
{DEADLINE_STATUS_LABEL[d.status] ?? d.status}
</span>
<span className="whitespace-nowrap text-sm text-neutral-500">
{format(new Date(d.due_date), "d. MMM yyyy", { locale: de })}
</span>
</div>
</div>
))}
</div>
);
}
function DocumentsList({ documents }: { documents: Document[] }) {
if (documents.length === 0) {
return (
<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>
);
}
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">
<FileText className="h-4 w-4 text-neutral-400" />
<div>
<p className="text-sm font-medium text-neutral-900">
{doc.title}
</p>
<div className="flex gap-2 text-xs text-neutral-400">
{doc.doc_type && <span>{doc.doc_type}</span>}
{doc.file_size && (
<span>{(doc.file_size / 1024).toFixed(0)} KB</span>
)}
</div>
</div>
</div>
<a
href={`/api/documents/${doc.id}`}
className="text-sm text-neutral-500 transition-colors hover:text-neutral-700"
>
Herunterladen
</a>
</div>
))}
</div>
);
export default async function CaseDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
redirect(`/cases/${id}/verlauf`);
}

View File

@@ -0,0 +1,35 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useParams } from "next/navigation";
import { api } from "@/lib/api";
import type { Case, Party } from "@/lib/types";
import { PartyList } from "@/components/cases/PartyList";
import { Loader2 } from "lucide-react";
interface CaseDetail extends Case {
parties: Party[];
}
export default function ParteienPage() {
const { id } = useParams<{ id: string }>();
const { data: caseDetail, isLoading } = useQuery({
queryKey: ["case", id],
queryFn: () => api.get<CaseDetail>(`/cases/${id}`),
});
if (isLoading) {
return (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-neutral-400" />
</div>
);
}
const parties = Array.isArray(caseDetail?.parties)
? caseDetail.parties
: [];
return <PartyList caseId={id} parties={parties} />;
}

View File

@@ -0,0 +1,35 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useParams } from "next/navigation";
import { api } from "@/lib/api";
import type { Case, CaseEvent } from "@/lib/types";
import { CaseTimeline } from "@/components/cases/CaseTimeline";
import { Loader2 } from "lucide-react";
interface CaseDetail extends Case {
recent_events: CaseEvent[];
}
export default function VerlaufPage() {
const { id } = useParams<{ id: string }>();
const { data: caseDetail, isLoading } = useQuery({
queryKey: ["case", id],
queryFn: () => api.get<CaseDetail>(`/cases/${id}`),
});
if (isLoading) {
return (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-neutral-400" />
</div>
);
}
const events = Array.isArray(caseDetail?.recent_events)
? caseDetail.recent_events
: [];
return <CaseTimeline events={events} />;
}

View File

@@ -0,0 +1,33 @@
import Link from "next/link";
import { ChevronRight } from "lucide-react";
export interface BreadcrumbItem {
label: string;
href?: string;
}
interface BreadcrumbProps {
items: BreadcrumbItem[];
}
export function Breadcrumb({ items }: BreadcrumbProps) {
return (
<nav className="flex items-center gap-1 text-sm text-neutral-400">
{items.map((item, i) => (
<span key={i} className="flex items-center gap-1">
{i > 0 && <ChevronRight className="h-3.5 w-3.5" />}
{item.href ? (
<Link
href={item.href}
className="transition-colors hover:text-neutral-700"
>
{item.label}
</Link>
) : (
<span className="text-neutral-600">{item.label}</span>
)}
</span>
))}
</nav>
);
}

View File

@@ -0,0 +1,223 @@
"use client";
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import { Plus, Pencil, Trash2, X, Loader2, StickyNote } from "lucide-react";
import { toast } from "sonner";
import { api } from "@/lib/api";
import type { Note } from "@/lib/types";
type ParentType = "case" | "deadline" | "appointment" | "case_event";
interface NotesListProps {
parentType: ParentType;
parentId: string;
}
const PARENT_PARAM: Record<ParentType, string> = {
case: "case_id",
deadline: "deadline_id",
appointment: "appointment_id",
case_event: "case_event_id",
};
export function NotesList({ parentType, parentId }: NotesListProps) {
const queryClient = useQueryClient();
const queryKey = ["notes", parentType, parentId];
const paramKey = PARENT_PARAM[parentType];
const [showForm, setShowForm] = useState(false);
const [newContent, setNewContent] = useState("");
const [editingId, setEditingId] = useState<string | null>(null);
const [editContent, setEditContent] = useState("");
const { data, isLoading } = useQuery({
queryKey,
queryFn: () => api.get<Note[]>(`/notes?${paramKey}=${parentId}`),
});
const notes = Array.isArray(data) ? data : [];
const createMutation = useMutation({
mutationFn: (content: string) =>
api.post<Note>("/notes", { [paramKey]: parentId, content }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey });
setNewContent("");
setShowForm(false);
toast.success("Notiz erstellt");
},
onError: () => toast.error("Fehler beim Erstellen"),
});
const updateMutation = useMutation({
mutationFn: ({ id, content }: { id: string; content: string }) =>
api.put<Note>(`/notes/${id}`, { content }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey });
setEditingId(null);
toast.success("Notiz aktualisiert");
},
onError: () => toast.error("Fehler beim Aktualisieren"),
});
const deleteMutation = useMutation({
mutationFn: (id: string) => api.delete(`/notes/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey });
toast.success("Notiz geloescht");
},
onError: () => toast.error("Fehler beim Loeschen"),
});
function startEdit(note: Note) {
setEditingId(note.id);
setEditContent(note.content);
}
if (isLoading) {
return (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-neutral-400" />
</div>
);
}
return (
<div>
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-neutral-700">
Notizen ({notes.length})
</h3>
{!showForm && (
<button
onClick={() => setShowForm(true)}
className="inline-flex items-center gap-1 text-sm text-neutral-500 transition-colors hover:text-neutral-700"
>
<Plus className="h-3.5 w-3.5" />
Neu
</button>
)}
</div>
{showForm && (
<div className="mt-3 rounded-md border border-neutral-200 bg-neutral-50 p-3">
<textarea
value={newContent}
onChange={(e) => setNewContent(e.target.value)}
placeholder="Notiz eingeben..."
rows={3}
className="w-full rounded-md border border-neutral-200 bg-white px-3 py-2 text-sm outline-none transition-colors focus:border-neutral-400 focus:ring-1 focus:ring-neutral-400"
autoFocus
/>
<div className="mt-2 flex justify-end gap-2">
<button
onClick={() => {
setShowForm(false);
setNewContent("");
}}
className="rounded-md px-3 py-1.5 text-sm text-neutral-500 hover:bg-neutral-200"
>
Abbrechen
</button>
<button
onClick={() => {
if (!newContent.trim()) return;
createMutation.mutate(newContent.trim());
}}
disabled={createMutation.isPending || !newContent.trim()}
className="rounded-md bg-neutral-900 px-3 py-1.5 text-sm font-medium text-white hover:bg-neutral-800 disabled:opacity-50"
>
{createMutation.isPending ? "Speichern..." : "Speichern"}
</button>
</div>
</div>
)}
{notes.length === 0 && !showForm && (
<div className="mt-4 flex flex-col items-center py-6 text-center">
<div className="rounded-xl bg-neutral-100 p-3">
<StickyNote className="h-5 w-5 text-neutral-400" />
</div>
<p className="mt-2 text-sm text-neutral-500">
Keine Notizen vorhanden.
</p>
</div>
)}
<div className="mt-3 space-y-2">
{notes.map((note) => (
<div
key={note.id}
className="rounded-md border border-neutral-200 bg-white px-4 py-3"
>
{editingId === note.id ? (
<div>
<textarea
value={editContent}
onChange={(e) => setEditContent(e.target.value)}
rows={3}
className="w-full rounded-md border border-neutral-200 bg-white px-3 py-2 text-sm outline-none transition-colors focus:border-neutral-400 focus:ring-1 focus:ring-neutral-400"
autoFocus
/>
<div className="mt-2 flex justify-end gap-2">
<button
onClick={() => setEditingId(null)}
className="rounded-md px-3 py-1.5 text-sm text-neutral-500 hover:bg-neutral-100"
>
Abbrechen
</button>
<button
onClick={() => {
if (!editContent.trim()) return;
updateMutation.mutate({
id: note.id,
content: editContent.trim(),
});
}}
disabled={updateMutation.isPending || !editContent.trim()}
className="rounded-md bg-neutral-900 px-3 py-1.5 text-sm font-medium text-white hover:bg-neutral-800 disabled:opacity-50"
>
{updateMutation.isPending ? "Speichern..." : "Speichern"}
</button>
</div>
</div>
) : (
<div>
<div className="flex items-start justify-between">
<p className="whitespace-pre-wrap text-sm text-neutral-800">
{note.content}
</p>
<div className="ml-3 flex shrink-0 gap-1">
<button
onClick={() => startEdit(note)}
className="rounded p-1 text-neutral-300 transition-colors hover:bg-neutral-100 hover:text-neutral-600"
title="Bearbeiten"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
onClick={() => deleteMutation.mutate(note.id)}
className="rounded p-1 text-neutral-300 transition-colors hover:bg-neutral-100 hover:text-red-500"
title="Loeschen"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
<p className="mt-1 text-xs text-neutral-400">
{format(new Date(note.created_at), "d. MMM yyyy, HH:mm", {
locale: de,
})}
{note.updated_at !== note.created_at && " (bearbeitet)"}
</p>
</div>
)}
</div>
))}
</div>
</div>
);
}

View File

@@ -176,6 +176,19 @@ export interface CalDAVSyncResponse {
last_sync_at?: null;
}
export interface Note {
id: string;
tenant_id: string;
case_id?: string;
deadline_id?: string;
appointment_id?: string;
case_event_id?: string;
content: string;
created_by?: string;
created_at: string;
updated_at: string;
}
export interface ApiError {
error: string;
status: number;