feat: persistent document viewing, archive page, and multi-format export (AIIA-83)
All checks were successful
Deploy to VPS / deploy (push) Successful in 1m7s
All checks were successful
Deploy to VPS / deploy (push) Successful in 1m7s
- Add document download, view, and text export endpoints via ?action= query param - Add /dokumente archive page with category filtering, pagination, and text viewer modal - Add /analyse/[id] detail page for viewing analysis results with text export - Make analysis list items clickable (link to detail view) - Add download/export buttons to existing document upload component - Add Dokumente nav item to sidebar Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
37
src/app/(dashboard)/analyse/[id]/analyse-detail.tsx
Normal file
37
src/app/(dashboard)/analyse/[id]/analyse-detail.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
'use client';
|
||||
|
||||
interface Props {
|
||||
analysisId: string;
|
||||
result: string | null;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export default function AnalyseDetail({ analysisId, result, title }: Props) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-2">
|
||||
{result && (
|
||||
<a
|
||||
href={`/api/analyses/${analysisId}?action=export-txt`}
|
||||
className="px-4 py-2 text-sm font-medium bg-primary/10 text-primary rounded-lg hover:bg-primary/20 transition-colors"
|
||||
>
|
||||
Als Text exportieren
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{result ? (
|
||||
<div className="bg-card-bg border border-card-border rounded-xl p-6">
|
||||
<h3 className="text-sm font-semibold text-foreground mb-4">Ergebnis</h3>
|
||||
<div className="prose prose-sm max-w-none text-foreground whitespace-pre-wrap">
|
||||
{result}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-card-bg border border-card-border rounded-xl p-8 text-center">
|
||||
<p className="text-muted text-sm">Noch kein Ergebnis verfuegbar.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
88
src/app/(dashboard)/analyse/[id]/page.tsx
Normal file
88
src/app/(dashboard)/analyse/[id]/page.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from '@/lib/auth';
|
||||
import { withTenantDb } from '@/lib/db';
|
||||
import { analyses } from '@/lib/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { notFound } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import AnalyseDetail from './analyse-detail';
|
||||
|
||||
const MODE_LABELS: Record<string, string> = {
|
||||
gutachten: 'Gutachten',
|
||||
entscheidung: 'Entscheidungsprognose',
|
||||
vergleich: 'Vergleichsanalyse',
|
||||
risiko: 'Risikoanalyse',
|
||||
};
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
draft: 'Entwurf',
|
||||
in_progress: 'In Bearbeitung',
|
||||
completed: 'Abgeschlossen',
|
||||
archived: 'Archiviert',
|
||||
};
|
||||
|
||||
export default async function AnalyseDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const session = await getServerSession(authOptions);
|
||||
const tenantId = session!.user.tenantId;
|
||||
const { id } = await params;
|
||||
|
||||
const analysis = await withTenantDb(tenantId, async (tdb) => {
|
||||
const [a] = await tdb
|
||||
.select()
|
||||
.from(analyses)
|
||||
.where(eq(analyses.id, id))
|
||||
.limit(1);
|
||||
return a ?? null;
|
||||
});
|
||||
|
||||
if (!analysis) notFound();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
href="/analyse"
|
||||
className="text-sm text-muted hover:text-foreground transition-colors"
|
||||
>
|
||||
← Zurueck
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-foreground">{analysis.title}</h2>
|
||||
<p className="text-sm text-muted mt-1">
|
||||
{MODE_LABELS[analysis.mode] ?? analysis.mode}
|
||||
{' · '}
|
||||
{STATUS_LABELS[analysis.status] ?? analysis.status}
|
||||
{' · '}
|
||||
{new Date(analysis.createdAt).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{analysis.query && (
|
||||
<div className="bg-card-bg border border-card-border rounded-xl p-5">
|
||||
<h3 className="text-sm font-semibold text-foreground mb-2">Fragestellung</h3>
|
||||
<p className="text-sm text-muted whitespace-pre-wrap">{analysis.query}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AnalyseDetail
|
||||
analysisId={analysis.id}
|
||||
result={analysis.result}
|
||||
title={analysis.title}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -89,7 +89,7 @@ export default async function AnalysePage() {
|
||||
<h3 className="text-lg font-semibold text-foreground mb-4">Bisherige Analysen</h3>
|
||||
<div className="bg-card-bg border border-card-border rounded-xl divide-y divide-card-border">
|
||||
{recentAnalyses.map((a) => (
|
||||
<div key={a.id} className="px-5 py-4 flex items-center justify-between">
|
||||
<Link key={a.id} href={`/analyse/${a.id}`} className="px-5 py-4 flex items-center justify-between hover:bg-gray-50 transition-colors">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">{a.title || 'Ohne Titel'}</p>
|
||||
<p className="text-xs text-muted mt-0.5">
|
||||
@@ -99,7 +99,7 @@ export default async function AnalysePage() {
|
||||
<span className="text-xs text-muted">
|
||||
{new Date(a.createdAt).toLocaleDateString('de-DE')}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
235
src/app/(dashboard)/dokumente/dokumente-archiv.tsx
Normal file
235
src/app/(dashboard)/dokumente/dokumente-archiv.tsx
Normal file
@@ -0,0 +1,235 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
interface DocumentItem {
|
||||
id: string;
|
||||
filename: string;
|
||||
mimeType: string;
|
||||
fileSizeBytes: number;
|
||||
category: string;
|
||||
sourceScope: string;
|
||||
status: string;
|
||||
errorMessage: string | null;
|
||||
caseId: string | null;
|
||||
createdAt: Date | string;
|
||||
updatedAt: Date | string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
documents: DocumentItem[];
|
||||
currentCategory: string;
|
||||
currentPage: number;
|
||||
categoryLabels: Record<string, string>;
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
uploaded: 'Hochgeladen',
|
||||
extracting: 'Extrahiere...',
|
||||
extracted: 'Extrahiert',
|
||||
failed: 'Fehlgeschlagen',
|
||||
};
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
uploaded: 'bg-blue-500/10 text-blue-700',
|
||||
extracting: 'bg-yellow-500/10 text-yellow-700',
|
||||
extracted: 'bg-green-500/10 text-green-700',
|
||||
failed: 'bg-red-500/10 text-red-700',
|
||||
};
|
||||
|
||||
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`;
|
||||
}
|
||||
|
||||
export default function DokumenteArchiv({ documents, currentCategory, currentPage, categoryLabels }: Props) {
|
||||
const router = useRouter();
|
||||
const [viewingDoc, setViewingDoc] = useState<{ id: string; filename: string; text: string } | null>(null);
|
||||
const [loading, setLoading] = useState<string | null>(null);
|
||||
|
||||
async function handleView(doc: DocumentItem) {
|
||||
if (doc.status !== 'extracted') return;
|
||||
setLoading(doc.id);
|
||||
try {
|
||||
const res = await fetch(`/api/documents/${doc.id}?action=view`);
|
||||
if (!res.ok) throw new Error('Laden fehlgeschlagen');
|
||||
const data = await res.json();
|
||||
setViewingDoc({
|
||||
id: doc.id,
|
||||
filename: doc.filename,
|
||||
text: data.extractedText || 'Kein Text verfuegbar.',
|
||||
});
|
||||
} catch {
|
||||
alert('Dokument konnte nicht geladen werden.');
|
||||
} finally {
|
||||
setLoading(null);
|
||||
}
|
||||
}
|
||||
|
||||
function handleCategoryChange(cat: string) {
|
||||
const params = new URLSearchParams();
|
||||
if (cat !== 'all') params.set('category', cat);
|
||||
router.push(`/dokumente?${params}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Category filter */}
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{['all', ...Object.keys(categoryLabels)].map((cat) => (
|
||||
<button
|
||||
key={cat}
|
||||
onClick={() => handleCategoryChange(cat)}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
|
||||
currentCategory === cat
|
||||
? 'bg-primary text-white'
|
||||
: 'bg-card-bg border border-card-border text-muted hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{cat === 'all' ? 'Alle' : categoryLabels[cat] ?? cat}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Document list */}
|
||||
{documents.length === 0 ? (
|
||||
<div className="bg-card-bg border border-card-border rounded-xl p-8 text-center">
|
||||
<p className="text-muted text-sm">Keine Dokumente gefunden.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-card-bg border border-card-border rounded-xl divide-y divide-card-border">
|
||||
{documents.map((doc) => (
|
||||
<div key={doc.id} className="px-5 py-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-foreground truncate">{doc.filename}</p>
|
||||
<p className="text-xs text-muted mt-0.5">
|
||||
{formatFileSize(doc.fileSizeBytes)}
|
||||
{' · '}
|
||||
{categoryLabels[doc.category] ?? doc.category}
|
||||
{' · '}
|
||||
{new Date(doc.createdAt).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<span
|
||||
className={`text-xs px-2 py-0.5 rounded-full font-medium shrink-0 ${
|
||||
STATUS_COLORS[doc.status] ?? 'bg-gray-500/10 text-gray-600'
|
||||
}`}
|
||||
>
|
||||
{STATUS_LABELS[doc.status] ?? doc.status}
|
||||
</span>
|
||||
|
||||
<div className="flex gap-2 shrink-0">
|
||||
{doc.status === 'extracted' && (
|
||||
<button
|
||||
onClick={() => handleView(doc)}
|
||||
disabled={loading === doc.id}
|
||||
className="px-3 py-1.5 text-xs font-medium bg-primary/10 text-primary rounded-lg hover:bg-primary/20 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading === doc.id ? '...' : 'Ansehen'}
|
||||
</button>
|
||||
)}
|
||||
<a
|
||||
href={`/api/documents/${doc.id}?action=download`}
|
||||
className="px-3 py-1.5 text-xs font-medium bg-card-bg border border-card-border text-foreground rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Herunterladen
|
||||
</a>
|
||||
{doc.status === 'extracted' && (
|
||||
<a
|
||||
href={`/api/documents/${doc.id}?action=export-txt`}
|
||||
className="px-3 py-1.5 text-xs font-medium bg-card-bg border border-card-border text-foreground rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Als Text
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{doc.status === 'failed' && doc.errorMessage && (
|
||||
<div className="mt-2 bg-red-50 border border-red-200 rounded-lg p-2 text-xs text-red-700">
|
||||
{doc.errorMessage}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="flex gap-2 justify-center">
|
||||
{currentPage > 1 && (
|
||||
<button
|
||||
onClick={() => {
|
||||
const params = new URLSearchParams();
|
||||
if (currentCategory !== 'all') params.set('category', currentCategory);
|
||||
params.set('page', String(currentPage - 1));
|
||||
router.push(`/dokumente?${params}`);
|
||||
}}
|
||||
className="px-4 py-2 text-sm bg-card-bg border border-card-border rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
Zurueck
|
||||
</button>
|
||||
)}
|
||||
{documents.length === 20 && (
|
||||
<button
|
||||
onClick={() => {
|
||||
const params = new URLSearchParams();
|
||||
if (currentCategory !== 'all') params.set('category', currentCategory);
|
||||
params.set('page', String(currentPage + 1));
|
||||
router.push(`/dokumente?${params}`);
|
||||
}}
|
||||
className="px-4 py-2 text-sm bg-card-bg border border-card-border rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
Weiter
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Document viewer modal */}
|
||||
{viewingDoc && (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-white rounded-2xl shadow-xl max-w-4xl w-full max-h-[90vh] flex flex-col">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b">
|
||||
<h3 className="text-sm font-semibold text-foreground truncate">{viewingDoc.filename}</h3>
|
||||
<div className="flex gap-2 shrink-0">
|
||||
<a
|
||||
href={`/api/documents/${viewingDoc.id}?action=download`}
|
||||
className="px-3 py-1.5 text-xs font-medium bg-primary/10 text-primary rounded-lg hover:bg-primary/20 transition-colors"
|
||||
>
|
||||
Original herunterladen
|
||||
</a>
|
||||
<a
|
||||
href={`/api/documents/${viewingDoc.id}?action=export-txt`}
|
||||
className="px-3 py-1.5 text-xs font-medium bg-primary/10 text-primary rounded-lg hover:bg-primary/20 transition-colors"
|
||||
>
|
||||
Als Text exportieren
|
||||
</a>
|
||||
<button
|
||||
onClick={() => setViewingDoc(null)}
|
||||
className="px-3 py-1.5 text-xs font-medium bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
Schliessen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
<pre className="whitespace-pre-wrap text-sm text-foreground font-sans leading-relaxed">
|
||||
{viewingDoc.text}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
69
src/app/(dashboard)/dokumente/page.tsx
Normal file
69
src/app/(dashboard)/dokumente/page.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from '@/lib/auth';
|
||||
import { db } from '@/lib/db';
|
||||
import { documents } from '@/lib/db/schema';
|
||||
import { desc, eq, and, ilike } from 'drizzle-orm';
|
||||
import DokumenteArchiv from './dokumente-archiv';
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
entscheidung: 'Entscheidung',
|
||||
norm: 'Norm',
|
||||
falldokument: 'Falldokument',
|
||||
sonstiges: 'Sonstiges',
|
||||
};
|
||||
|
||||
export default async function DokumentePage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ category?: string; page?: string }>;
|
||||
}) {
|
||||
const session = await getServerSession(authOptions);
|
||||
const tenantId = session!.user.tenantId;
|
||||
const { category, page } = await searchParams;
|
||||
const currentPage = parseInt(page ?? '1', 10);
|
||||
const pageSize = 20;
|
||||
const offset = (currentPage - 1) * pageSize;
|
||||
|
||||
const conditions = [eq(documents.tenantId, tenantId)];
|
||||
if (category && category !== 'all') {
|
||||
conditions.push(eq(documents.category, category as any));
|
||||
}
|
||||
|
||||
const docs = await db
|
||||
.select({
|
||||
id: documents.id,
|
||||
filename: documents.filename,
|
||||
mimeType: documents.mimeType,
|
||||
fileSizeBytes: documents.fileSizeBytes,
|
||||
category: documents.category,
|
||||
sourceScope: documents.sourceScope,
|
||||
status: documents.status,
|
||||
errorMessage: documents.errorMessage,
|
||||
caseId: documents.caseId,
|
||||
createdAt: documents.createdAt,
|
||||
updatedAt: documents.updatedAt,
|
||||
})
|
||||
.from(documents)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(documents.createdAt))
|
||||
.limit(pageSize)
|
||||
.offset(offset);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-foreground">Dokumentenarchiv</h2>
|
||||
<p className="text-sm text-muted mt-1">
|
||||
Alle hochgeladenen Dokumente einsehen, herunterladen und exportieren.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DokumenteArchiv
|
||||
documents={docs}
|
||||
currentCategory={category ?? 'all'}
|
||||
currentPage={currentPage}
|
||||
categoryLabels={CATEGORY_LABELS}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +1,25 @@
|
||||
// GET /api/analyses/:id — Retrieve a single analysis with its sources
|
||||
// Supports ?action=export-txt for plain-text export
|
||||
|
||||
import { type NextRequest } from 'next/server';
|
||||
import { withTenantDb } from '@/lib/db';
|
||||
import { analyses, norms, normInstruments, decisions } from '@/lib/db/schema';
|
||||
import { eq, inArray } from 'drizzle-orm';
|
||||
import { logAuditEvent } from '@/lib/auth/audit';
|
||||
import { requirePermission } from '@/lib/auth/rbac';
|
||||
import type { TenantContext } from '@/lib/auth';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const auth = await requirePermission('analyses:read');
|
||||
if ('response' in auth) return auth.response;
|
||||
const { ctx } = auth;
|
||||
const { id } = await params;
|
||||
|
||||
const tenantId = request.headers.get('x-tenant-id');
|
||||
if (!tenantId) {
|
||||
return Response.json({ error: 'Missing x-tenant-id header' }, { status: 401 });
|
||||
}
|
||||
|
||||
// RLS enforces tenant isolation — only rows matching app.tenant_id are visible.
|
||||
const result = await withTenantDb(tenantId, async (tdb) => {
|
||||
const result = await withTenantDb(ctx.tenantId, async (tdb) => {
|
||||
const [analysis] = await tdb
|
||||
.select()
|
||||
.from(analyses)
|
||||
@@ -72,14 +72,33 @@ export async function GET(
|
||||
return Response.json({ error: 'Analysis not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const userId = request.headers.get('x-user-id') ?? 'unknown';
|
||||
const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
|
||||
?? request.headers.get('x-real-ip')
|
||||
?? undefined;
|
||||
await logAuditEvent(
|
||||
{ tenantId, userId } as TenantContext,
|
||||
'read', 'analysis', id, undefined, ip,
|
||||
);
|
||||
|
||||
const action = request.nextUrl.searchParams.get('action');
|
||||
|
||||
// Export analysis as plain text
|
||||
if (action === 'export-txt') {
|
||||
if (!result.analysis.result) {
|
||||
return Response.json(
|
||||
{ error: 'Analyse hat noch kein Ergebnis.' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
await logAuditEvent(ctx, 'export', 'analysis', id, { format: 'txt' }, ip);
|
||||
|
||||
const safeTitle = (result.analysis.title || 'analyse').replace(/[^a-zA-Z0-9äöüÄÖÜß_-]/g, '_');
|
||||
return new Response(result.analysis.result, {
|
||||
headers: {
|
||||
'Content-Type': 'text/plain; charset=utf-8',
|
||||
'Content-Disposition': `attachment; filename="${encodeURIComponent(safeTitle)}.txt"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await logAuditEvent(ctx, 'read', 'analysis', id, undefined, ip);
|
||||
|
||||
return Response.json({
|
||||
...result.analysis,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// GET /api/documents/:id — get document status and metadata (used for polling)
|
||||
// GET /api/documents/:id — get document metadata, or download/export the file
|
||||
// DELETE /api/documents/:id — delete a document and its stored file
|
||||
|
||||
import { type NextRequest } from 'next/server';
|
||||
@@ -22,6 +22,72 @@ export async function GET(
|
||||
return Response.json({ error: 'Dokument nicht gefunden.' }, { status: 404 });
|
||||
}
|
||||
|
||||
const action = request.nextUrl.searchParams.get('action');
|
||||
|
||||
// Download the original file
|
||||
if (action === 'download') {
|
||||
const fs = await import('node:fs/promises');
|
||||
try {
|
||||
await fs.access(doc.storagePath);
|
||||
} catch {
|
||||
return Response.json({ error: 'Datei nicht gefunden.' }, { status: 404 });
|
||||
}
|
||||
const fileBuffer = await fs.readFile(doc.storagePath);
|
||||
|
||||
const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
|
||||
?? request.headers.get('x-real-ip')
|
||||
?? undefined;
|
||||
await logAuditEvent(ctx, 'download', 'document', id, { filename: doc.filename }, ip);
|
||||
|
||||
return new Response(fileBuffer, {
|
||||
headers: {
|
||||
'Content-Type': doc.mimeType,
|
||||
'Content-Disposition': `attachment; filename="${encodeURIComponent(doc.filename)}"`,
|
||||
'Content-Length': String(fileBuffer.length),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Export as plain text (extracted text)
|
||||
if (action === 'export-txt') {
|
||||
if (!doc.extractedText) {
|
||||
return Response.json(
|
||||
{ error: 'Kein extrahierter Text verfuegbar. Status: ' + doc.status },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
|
||||
?? request.headers.get('x-real-ip')
|
||||
?? undefined;
|
||||
await logAuditEvent(ctx, 'export', 'document', id, { format: 'txt', filename: doc.filename }, ip);
|
||||
|
||||
const baseName = doc.filename.replace(/\.[^.]+$/, '');
|
||||
return new Response(doc.extractedText, {
|
||||
headers: {
|
||||
'Content-Type': 'text/plain; charset=utf-8',
|
||||
'Content-Disposition': `attachment; filename="${encodeURIComponent(baseName)}.txt"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// View extracted text (inline, for the UI viewer)
|
||||
if (action === 'view') {
|
||||
return Response.json({
|
||||
id: doc.id,
|
||||
filename: doc.filename,
|
||||
mimeType: doc.mimeType,
|
||||
fileSizeBytes: doc.fileSizeBytes,
|
||||
category: doc.category,
|
||||
status: doc.status,
|
||||
errorMessage: doc.errorMessage,
|
||||
extractedText: doc.extractedText ?? null,
|
||||
createdAt: doc.createdAt,
|
||||
updatedAt: doc.updatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
// Default: metadata only
|
||||
return Response.json({
|
||||
id: doc.id,
|
||||
filename: doc.filename,
|
||||
|
||||
@@ -332,6 +332,22 @@ export default function DokumentUpload({
|
||||
>
|
||||
{STATUS_LABELS[doc.status] ?? doc.status}
|
||||
</span>
|
||||
<a
|
||||
href={`/api/documents/${doc.id}?action=download`}
|
||||
className="ml-2 text-xs text-primary hover:text-primary/80 transition-colors shrink-0"
|
||||
title="Herunterladen"
|
||||
>
|
||||
↓
|
||||
</a>
|
||||
{doc.status === 'extracted' && (
|
||||
<a
|
||||
href={`/api/documents/${doc.id}?action=export-txt`}
|
||||
className="ml-1 text-xs text-primary hover:text-primary/80 transition-colors shrink-0"
|
||||
title="Als Text exportieren"
|
||||
>
|
||||
TXT
|
||||
</a>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(doc.id, doc.filename)}
|
||||
|
||||
@@ -10,6 +10,7 @@ const NAV_ITEMS = [
|
||||
{ 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' },
|
||||
{ href: '/vertraege', label: 'Verträge', 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: '/dokumente', label: 'Dokumente', icon: 'M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z' },
|
||||
{ href: '/verfahren', label: 'Verfahren', icon: 'M3 6l3 1m0 0l-3 9a5.002 5.002 0 006.001 0M6 7l3 9M6 7l6-2m6 2l3-1m-3 1l-3 9a5.002 5.002 0 006.001 0M18 7l3 9m-3-9l-6-2m0-2v2m0 16V5m0 16H9m3 0h3' },
|
||||
{ href: '/einstellungen', label: 'Einstellungen', icon: 'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z M15 12a3 3 0 11-6 0 3 3 0 016 0z' },
|
||||
] as const;
|
||||
|
||||
Reference in New Issue
Block a user