feat: implement case management (Fallverwaltung) UI and API

- API routes: GET/POST /api/cases (list + create), GET/PATCH/DELETE /api/cases/[id]
- Cases list page with search, status filter, and pagination
- Case detail page showing linked analyses and proceedings
- Sidebar navigation: added "Fälle" link after Dashboard
- Tenant isolation via withTenantDb + requirePermission on all API routes
- Audit logging on all case operations

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
CTO (LegalAI)
2026-04-09 08:38:21 +00:00
parent 07a057bf79
commit f0c87d9332
5 changed files with 659 additions and 0 deletions

View File

@@ -0,0 +1,194 @@
import { db } from '@/lib/db';
import { cases, analyses, proceedings } from '@/lib/db/schema';
import { eq, desc } from 'drizzle-orm';
import Link from 'next/link';
import { notFound } from 'next/navigation';
const STATUS_LABELS: Record<string, string> = {
active: 'Aktiv',
closed: 'Abgeschlossen',
archived: 'Archiviert',
};
const STATUS_COLORS: Record<string, string> = {
active: 'bg-green-500/10 text-green-700',
closed: 'bg-gray-500/10 text-gray-600',
archived: 'bg-yellow-500/10 text-yellow-700',
};
export default async function CaseDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const [caseRow] = await db
.select()
.from(cases)
.where(eq(cases.id, id))
.limit(1);
if (!caseRow) {
notFound();
}
const [caseAnalyses, caseProceedings] = await Promise.all([
db
.select({
id: analyses.id,
title: analyses.title,
mode: analyses.mode,
status: analyses.status,
createdAt: analyses.createdAt,
})
.from(analyses)
.where(eq(analyses.caseId, id))
.orderBy(desc(analyses.createdAt)),
db
.select({
id: proceedings.id,
type: proceedings.type,
status: proceedings.status,
filingDate: proceedings.filingDate,
internalRef: proceedings.internalRef,
updatedAt: proceedings.updatedAt,
})
.from(proceedings)
.where(eq(proceedings.caseId, id))
.orderBy(desc(proceedings.updatedAt)),
]);
return (
<div className="space-y-6">
<div className="flex items-center gap-2 text-sm text-muted">
<Link href="/cases" className="hover:text-primary transition-colors">
Faelle
</Link>
<span>/</span>
<span>{caseRow.caseNumber}</span>
</div>
<div className="bg-card-bg border border-card-border rounded-xl p-6">
<div className="flex items-start justify-between mb-4">
<div>
<h1 className="text-xl font-bold text-foreground mb-1">{caseRow.title}</h1>
<p className="text-sm text-muted">Az. {caseRow.caseNumber}</p>
</div>
<span className={`text-xs px-2.5 py-1 rounded-full font-medium ${STATUS_COLORS[caseRow.status] ?? 'bg-gray-500/10 text-gray-600'}`}>
{STATUS_LABELS[caseRow.status] ?? caseRow.status}
</span>
</div>
{caseRow.description && (
<p className="text-sm text-muted mb-4">{caseRow.description}</p>
)}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
{caseRow.clientName && (
<div>
<span className="text-xs text-muted block mb-0.5">Mandant</span>
<span className="font-medium">{caseRow.clientName}</span>
</div>
)}
{caseRow.opposingParty && (
<div>
<span className="text-xs text-muted block mb-0.5">Gegenseite</span>
<span className="font-medium">{caseRow.opposingParty}</span>
</div>
)}
{caseRow.venue && (
<div>
<span className="text-xs text-muted block mb-0.5">Buehne / Spielstaette</span>
<span className="font-medium">{caseRow.venue}</span>
</div>
)}
{caseRow.filingDate && (
<div>
<span className="text-xs text-muted block mb-0.5">Eingereicht</span>
<span className="font-medium">{new Date(caseRow.filingDate).toLocaleDateString('de-DE')}</span>
</div>
)}
{caseRow.hearingDate && (
<div>
<span className="text-xs text-muted block mb-0.5">Verhandlung</span>
<span className="font-medium">{new Date(caseRow.hearingDate).toLocaleDateString('de-DE')}</span>
</div>
)}
<div>
<span className="text-xs text-muted block mb-0.5">Erstellt</span>
<span className="font-medium">{new Date(caseRow.createdAt).toLocaleDateString('de-DE')}</span>
</div>
<div>
<span className="text-xs text-muted block mb-0.5">Aktualisiert</span>
<span className="font-medium">{new Date(caseRow.updatedAt).toLocaleDateString('de-DE')}</span>
</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="bg-card-bg border border-card-border rounded-xl p-6">
<h2 className="text-sm font-semibold text-foreground mb-4">
Analysen ({caseAnalyses.length})
</h2>
{caseAnalyses.length === 0 ? (
<p className="text-sm text-muted">Keine Analysen fuer diesen Fall.</p>
) : (
<div className="space-y-2">
{caseAnalyses.map((a) => (
<Link
key={a.id}
href={`/analyse?id=${a.id}`}
className="block p-3 rounded-lg border border-card-border hover:shadow-sm transition-shadow"
>
<div className="flex items-center justify-between">
<span className="text-sm font-medium">{a.title}</span>
<span className="text-xs px-2 py-0.5 rounded-full bg-primary/10 text-primary font-medium">
{a.mode}
</span>
</div>
<div className="text-xs text-muted mt-1">
{a.status} {new Date(a.createdAt).toLocaleDateString('de-DE')}
</div>
</Link>
))}
</div>
)}
</div>
<div className="bg-card-bg border border-card-border rounded-xl p-6">
<h2 className="text-sm font-semibold text-foreground mb-4">
Verfahren ({caseProceedings.length})
</h2>
{caseProceedings.length === 0 ? (
<p className="text-sm text-muted">Keine Verfahren fuer diesen Fall.</p>
) : (
<div className="space-y-2">
{caseProceedings.map((p) => (
<Link
key={p.id}
href={`/verfahren?id=${p.id}`}
className="block p-3 rounded-lg border border-card-border hover:shadow-sm transition-shadow"
>
<div className="flex items-center justify-between">
<span className="text-sm font-medium">
{p.internalRef ?? p.type}
</span>
<span className="text-xs px-2 py-0.5 rounded-full bg-primary/10 text-primary font-medium">
{p.status}
</span>
</div>
<div className="text-xs text-muted mt-1">
{p.filingDate && `Eingereicht: ${new Date(p.filingDate).toLocaleDateString('de-DE')}`}
{p.filingDate && ' — '}
Aktualisiert: {new Date(p.updatedAt).toLocaleDateString('de-DE')}
</div>
</Link>
))}
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,165 @@
import { db } from '@/lib/db';
import { cases } from '@/lib/db/schema';
import { desc, ilike, or, eq } from 'drizzle-orm';
import Link from 'next/link';
const STATUS_LABELS: Record<string, string> = {
active: 'Aktiv',
closed: 'Abgeschlossen',
archived: 'Archiviert',
};
const STATUS_COLORS: Record<string, string> = {
active: 'bg-green-500/10 text-green-700',
closed: 'bg-gray-500/10 text-gray-600',
archived: 'bg-yellow-500/10 text-yellow-700',
};
export default async function CasesPage({
searchParams,
}: {
searchParams: Promise<{ q?: string; page?: string; status?: string }>;
}) {
const { q, page, status } = await searchParams;
const currentPage = parseInt(page ?? '1', 10);
const pageSize = 20;
const offset = (currentPage - 1) * pageSize;
let query = db
.select({
id: cases.id,
caseNumber: cases.caseNumber,
title: cases.title,
clientName: cases.clientName,
opposingParty: cases.opposingParty,
venue: cases.venue,
status: cases.status,
filingDate: cases.filingDate,
hearingDate: cases.hearingDate,
createdAt: cases.createdAt,
updatedAt: cases.updatedAt,
})
.from(cases)
.orderBy(desc(cases.updatedAt))
.limit(pageSize)
.offset(offset);
if (q) {
query = query.where(
or(
ilike(cases.title, `%${q}%`),
ilike(cases.caseNumber, `%${q}%`),
ilike(cases.clientName, `%${q}%`),
ilike(cases.description, `%${q}%`),
),
) as typeof query;
}
if (status) {
query = query.where(eq(cases.status, status)) as typeof query;
}
const results = await query;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted">
Fallverwaltung alle Mandate und Verfahren im Ueberblick.
</p>
</div>
<Link
href="/cases/new"
className="px-4 py-2.5 bg-primary text-white rounded-lg text-sm font-medium hover:bg-primary-light transition-colors"
>
Neuer Fall
</Link>
</div>
<form className="flex gap-2">
<input
name="q"
type="search"
defaultValue={q ?? ''}
placeholder="Suche nach Aktenzeichen, Titel, Mandant..."
className="flex-1 rounded-lg border border-card-border px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary bg-card-bg"
/>
<select
name="status"
defaultValue={status ?? ''}
className="rounded-lg border border-card-border px-3 py-2.5 text-sm bg-card-bg focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary"
>
<option value="">Alle Status</option>
<option value="active">Aktiv</option>
<option value="closed">Abgeschlossen</option>
<option value="archived">Archiviert</option>
</select>
<button
type="submit"
className="px-4 py-2.5 bg-primary text-white rounded-lg text-sm font-medium hover:bg-primary-light transition-colors"
>
Suchen
</button>
</form>
{results.length === 0 ? (
<div className="bg-card-bg border border-card-border rounded-xl p-8 text-center">
<p className="text-muted text-sm">
{q || status
? 'Keine Faelle fuer die angegebenen Kriterien gefunden.'
: 'Noch keine Faelle angelegt. Erstellen Sie den ersten Fall.'}
</p>
</div>
) : (
<div className="space-y-3">
{results.map((c) => (
<Link
key={c.id}
href={`/cases/${c.id}`}
className="block bg-card-bg border border-card-border rounded-xl p-5 hover:shadow-md transition-shadow"
>
<div className="flex items-start justify-between mb-2">
<div>
<h3 className="text-sm font-semibold text-foreground">
{c.caseNumber} {c.title}
</h3>
</div>
<div className="flex gap-2 shrink-0 ml-4">
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${STATUS_COLORS[c.status] ?? 'bg-gray-500/10 text-gray-600'}`}>
{STATUS_LABELS[c.status] ?? c.status}
</span>
</div>
</div>
<div className="flex items-center gap-4 text-xs text-muted mb-2">
{c.clientName && <span>Mandant: {c.clientName}</span>}
{c.opposingParty && <span>Gegenseite: {c.opposingParty}</span>}
{c.venue && <span>Buehne: {c.venue}</span>}
</div>
<div className="flex items-center gap-4 text-xs text-muted">
{c.filingDate && (
<span>Eingereicht: {new Date(c.filingDate).toLocaleDateString('de-DE')}</span>
)}
{c.hearingDate && (
<span>Verhandlung: {new Date(c.hearingDate).toLocaleDateString('de-DE')}</span>
)}
<span>Aktualisiert: {new Date(c.updatedAt).toLocaleDateString('de-DE')}</span>
</div>
</Link>
))}
</div>
)}
{results.length === pageSize && (
<div className="flex justify-center">
<Link
href={`/cases?${q ? `q=${encodeURIComponent(q)}&` : ''}${status ? `status=${encodeURIComponent(status)}&` : ''}page=${currentPage + 1}`}
className="text-sm text-primary font-medium hover:underline"
>
Weitere Ergebnisse laden
</Link>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,166 @@
// GET /api/cases/:id — get a case with related entities
// PATCH /api/cases/:id — update a case
// DELETE /api/cases/:id — delete a case
import { type NextRequest } from 'next/server';
import { withTenantDb } from '@/lib/db';
import { cases, analyses, proceedings } from '@/lib/db/schema';
import { eq, sql, desc } from 'drizzle-orm';
import { logAuditEvent } from '@/lib/auth/audit';
import { requirePermission } from '@/lib/auth/rbac';
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const auth = await requirePermission('cases:read');
if ('response' in auth) return auth.response;
const { ctx } = auth;
const { id } = await params;
const result = await withTenantDb(ctx.tenantId, async (tdb) => {
const [caseRow] = await tdb
.select()
.from(cases)
.where(eq(cases.id, id))
.limit(1);
if (!caseRow) return null;
const [caseAnalyses, caseProceedings] = await Promise.all([
tdb
.select({
id: analyses.id,
title: analyses.title,
mode: analyses.mode,
status: analyses.status,
createdAt: analyses.createdAt,
})
.from(analyses)
.where(eq(analyses.caseId, id))
.orderBy(desc(analyses.createdAt)),
tdb
.select({
id: proceedings.id,
type: proceedings.type,
status: proceedings.status,
filingDate: proceedings.filingDate,
internalRef: proceedings.internalRef,
updatedAt: proceedings.updatedAt,
})
.from(proceedings)
.where(eq(proceedings.caseId, id))
.orderBy(desc(proceedings.updatedAt)),
]);
return { case: caseRow, analyses: caseAnalyses, proceedings: caseProceedings };
});
if (!result) {
return Response.json({ error: 'Fall nicht gefunden.' }, { status: 404 });
}
const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
?? request.headers.get('x-real-ip')
?? undefined;
await logAuditEvent(ctx, 'read', 'case', id, undefined, ip);
return Response.json(result);
}
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const auth = await requirePermission('cases:edit');
if ('response' in auth) return auth.response;
const { ctx } = auth;
const { id } = await params;
let body: Record<string, unknown>;
try {
body = await request.json();
} catch {
return Response.json({ error: 'Ungueltige JSON-Daten.' }, { status: 400 });
}
const allowedFields = [
'caseNumber',
'title',
'description',
'clientName',
'opposingParty',
'venue',
'fachgruppeId',
'domains',
'status',
'filingDate',
'hearingDate',
'metadata',
] as const;
const updates: Record<string, unknown> = {};
for (const field of allowedFields) {
if (field in body) {
updates[field] = body[field];
}
}
if (body.status === 'closed') {
updates.closedAt = new Date();
}
updates.updatedAt = new Date();
const updated = await withTenantDb(ctx.tenantId, async (tdb) => {
const [row] = await tdb
.update(cases)
.set(updates)
.where(eq(cases.id, id))
.returning();
return row;
});
if (!updated) {
return Response.json({ error: 'Fall nicht gefunden.' }, { status: 404 });
}
const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
?? request.headers.get('x-real-ip')
?? undefined;
await logAuditEvent(ctx, 'update', 'case', id, updates, ip);
return Response.json({ case: updated });
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const auth = await requirePermission('cases:edit');
if ('response' in auth) return auth.response;
const { ctx } = auth;
const { id } = await params;
const deleted = await withTenantDb(ctx.tenantId, async (tdb) => {
const [row] = await tdb
.delete(cases)
.where(eq(cases.id, id))
.returning();
return row;
});
if (!deleted) {
return Response.json({ error: 'Fall nicht gefunden.' }, { status: 404 });
}
const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
?? request.headers.get('x-real-ip')
?? undefined;
await logAuditEvent(ctx, 'delete', 'case', id, undefined, ip);
return Response.json({ deleted: true });
}

133
src/app/api/cases/route.ts Normal file
View File

@@ -0,0 +1,133 @@
// GET /api/cases — list cases for the current tenant
// POST /api/cases — create a new case
import { type NextRequest } from 'next/server';
import { withTenantDb } from '@/lib/db';
import { cases } from '@/lib/db/schema';
import { desc, ilike, or, sql, type SQL, and } from 'drizzle-orm';
import { logAuditEvent } from '@/lib/auth/audit';
import { requirePermission } from '@/lib/auth/rbac';
export async function GET(request: NextRequest) {
const auth = await requirePermission('cases:read');
if ('response' in auth) return auth.response;
const { ctx } = auth;
const searchParams = request.nextUrl.searchParams;
const limit = Math.min(parseInt(searchParams.get('limit') ?? '20', 10), 100);
const offset = parseInt(searchParams.get('offset') ?? '0', 10);
const q = searchParams.get('q');
const status = searchParams.get('status');
const results = await withTenantDb(ctx.tenantId, async (tdb) => {
const conditions: SQL[] = [];
if (q) {
conditions.push(
or(
ilike(cases.title, `%${q}%`),
ilike(cases.caseNumber, `%${q}%`),
ilike(cases.clientName, `%${q}%`),
ilike(cases.description, `%${q}%`),
)!,
);
}
if (status) {
conditions.push(sql`${cases.status} = ${status}`);
}
const where = conditions.length > 0 ? and(...conditions) : undefined;
const [rows, countResult] = await Promise.all([
tdb
.select({
id: cases.id,
caseNumber: cases.caseNumber,
title: cases.title,
clientName: cases.clientName,
opposingParty: cases.opposingParty,
venue: cases.venue,
status: cases.status,
filingDate: cases.filingDate,
hearingDate: cases.hearingDate,
createdAt: cases.createdAt,
updatedAt: cases.updatedAt,
})
.from(cases)
.where(where)
.orderBy(desc(cases.updatedAt))
.limit(limit)
.offset(offset),
tdb
.select({ count: sql<number>`count(*)::int` })
.from(cases)
.where(where),
]);
return {
cases: rows,
pagination: {
total: countResult[0].count,
limit,
offset,
hasMore: offset + limit < countResult[0].count,
},
};
});
const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
?? request.headers.get('x-real-ip')
?? undefined;
await logAuditEvent(ctx, 'list', 'case', null, { limit, offset, q, status }, ip);
return Response.json(results);
}
export async function POST(request: NextRequest) {
const auth = await requirePermission('cases:create');
if ('response' in auth) return auth.response;
const { ctx } = auth;
let body: Record<string, unknown>;
try {
body = await request.json();
} catch {
return Response.json({ error: 'Ungueltige JSON-Daten.' }, { status: 400 });
}
const { caseNumber, title, description, clientName, opposingParty, venue, fachgruppeId, domains, status, filingDate, hearingDate } = body as any;
if (!caseNumber || !title) {
return Response.json(
{ error: 'caseNumber und title sind erforderlich.' },
{ status: 400 },
);
}
const created = await withTenantDb(ctx.tenantId, async (tdb) => {
const [row] = await tdb
.insert(cases)
.values({
tenantId: ctx.tenantId,
caseNumber,
title,
description: description ?? null,
clientName: clientName ?? null,
opposingParty: opposingParty ?? null,
venue: venue ?? null,
fachgruppeId: fachgruppeId ?? null,
domains: domains ?? [],
status: status ?? 'active',
filingDate: filingDate ?? null,
hearingDate: hearingDate ?? null,
})
.returning();
return row;
});
const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
?? request.headers.get('x-real-ip')
?? undefined;
await logAuditEvent(ctx, 'create', 'case', created.id, { caseNumber, title }, ip);
return Response.json({ case: created }, { status: 201 });
}

View File

@@ -5,6 +5,7 @@ import { usePathname } from 'next/navigation';
const NAV_ITEMS = [
{ href: '/dashboard', label: 'Dashboard', icon: 'M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6' },
{ href: '/cases', label: 'Fälle', icon: 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10' },
{ href: '/normen', label: 'Normen', icon: 'M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253' },
{ href: '/entscheidungen', label: 'Entscheidungen', icon: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z' },
{ href: '/analyse', label: 'Analyse', icon: 'M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z' },