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:
194
src/app/(dashboard)/cases/[id]/page.tsx
Normal file
194
src/app/(dashboard)/cases/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
165
src/app/(dashboard)/cases/page.tsx
Normal file
165
src/app/(dashboard)/cases/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
166
src/app/api/cases/[id]/route.ts
Normal file
166
src/app/api/cases/[id]/route.ts
Normal 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
133
src/app/api/cases/route.ts
Normal 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 });
|
||||
}
|
||||
@@ -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' },
|
||||
|
||||
Reference in New Issue
Block a user