feat: Dokumenten Upload im Normenrecherche-Tab

Drag & Drop Upload-Zone fuer kundeneigene PDFs (Normen, Spezifikationen).
Tenant-isoliert, Status-Tracking, Backend-Placeholder fuer RAG-Pipeline.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-05-09 08:07:58 +02:00
parent 9034a3071c
commit c89e46a828
2 changed files with 198 additions and 0 deletions
@@ -0,0 +1,197 @@
'use client'
import { useState, useEffect, useCallback, useRef } from 'react'
interface UploadedDoc {
id: string
filename: string
size_bytes: number
status: 'uploaded' | 'processing' | 'indexed'
created_at: string
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
const STATUS_BADGE: Record<string, { label: string; cls: string }> = {
uploaded: { label: 'Hochgeladen', cls: 'bg-gray-100 text-gray-600' },
processing: { label: 'Wird verarbeitet', cls: 'bg-yellow-100 text-yellow-700 animate-pulse' },
indexed: { label: 'Durchsuchbar', cls: 'bg-green-100 text-green-700' },
}
const MAX_FILE_SIZE = 50 * 1024 * 1024 // 50 MB
export function DocumentUpload({ projectId }: { projectId: string }) {
const [docs, setDocs] = useState<UploadedDoc[]>([])
const [loading, setLoading] = useState(true)
const [backendReady, setBackendReady] = useState(true)
const [collapsed, setCollapsed] = useState(true)
const [dragging, setDragging] = useState(false)
const [uploading, setUploading] = useState(false)
const [error, setError] = useState<string | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const base = `/api/sdk/v1/iace/projects/${projectId}/documents`
const fetchDocs = useCallback(async () => {
try {
const r = await fetch(base)
if (r.status === 404) { setBackendReady(false); return }
if (!r.ok) return
const json = await r.json()
setDocs(Array.isArray(json) ? json : json.documents ?? [])
} catch {
setBackendReady(false)
} finally {
setLoading(false)
}
}, [base])
useEffect(() => { fetchDocs() }, [fetchDocs])
const uploadFiles = async (files: FileList | File[]) => {
setError(null)
const valid = Array.from(files).filter((f) => {
if (!f.name.toLowerCase().endsWith('.pdf')) { setError('Nur PDF-Dateien erlaubt.'); return false }
if (f.size > MAX_FILE_SIZE) { setError(`${f.name} ist groesser als 50 MB.`); return false }
return true
})
if (valid.length === 0) return
setUploading(true)
for (const file of valid) {
const form = new FormData()
form.append('file', file)
try {
const r = await fetch(base, { method: 'POST', body: form })
if (r.status === 404) { setBackendReady(false); break }
if (!r.ok) { setError(`Fehler beim Hochladen von ${file.name}`); continue }
} catch {
setBackendReady(false)
break
}
}
setUploading(false)
await fetchDocs()
}
const deleteDoc = async (docId: string) => {
try {
const r = await fetch(`${base}/${docId}`, { method: 'DELETE' })
if (r.ok || r.status === 204) setDocs((prev) => prev.filter((d) => d.id !== docId))
} catch { /* ignore */ }
}
const onDrop = (e: React.DragEvent) => {
e.preventDefault()
setDragging(false)
if (e.dataTransfer.files.length) uploadFiles(e.dataTransfer.files)
}
return (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
{/* Header — collapsible toggle */}
<button onClick={() => setCollapsed(!collapsed)} className="w-full flex items-center justify-between p-6 text-left">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-purple-50 dark:bg-purple-900/30 rounded-lg flex items-center justify-center">
<svg className="w-5 h-5 text-purple-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
</div>
<div>
<h2 className="text-sm font-semibold text-gray-900 dark:text-white">
Dokumenten Upload{docs.length > 0 ? `${docs.length} Dokument${docs.length !== 1 ? 'e' : ''}` : ''}
</h2>
<p className="text-xs text-gray-500">Eigene Normen und technische Dokumente hochladen</p>
</div>
</div>
<div className="w-8 h-8 flex items-center justify-center rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors flex-shrink-0">
<svg className={`w-5 h-5 text-gray-400 transition-transform ${collapsed ? '' : 'rotate-180'}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
</button>
{!collapsed && (
<div className="px-6 pb-6 space-y-4">
{/* Info text */}
<p className="text-xs text-gray-500 dark:text-gray-400">
Laden Sie hier Ihre eigenen Dokumente hoch (z.B. Normen, technische Spezifikationen, Pruefberichte).
Die Dokumente werden fuer Ihr Unternehmen indexiert und stehen in der Normenrecherche als zusaetzliche
Quelle zur Verfuegung. Hochgeladene Dokumente sind nur fuer Ihren Mandanten sichtbar.
</p>
{!backendReady ? (
<div className="p-4 rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 text-xs text-amber-800 dark:text-amber-300">
<strong>Backend wird vorbereitet</strong> Die Dokumenten-Upload-Funktion wird derzeit eingerichtet.
Bitte versuchen Sie es spaeter erneut.
</div>
) : (
<>
{/* Drop zone */}
<div
onDragOver={(e) => { e.preventDefault(); setDragging(true) }}
onDragLeave={() => setDragging(false)}
onDrop={onDrop}
onClick={() => fileInputRef.current?.click()}
className={`relative flex flex-col items-center justify-center gap-2 p-8 rounded-lg border-2 border-dashed cursor-pointer transition-colors ${
dragging
? 'border-purple-500 bg-purple-50 dark:bg-purple-900/20'
: 'border-gray-300 dark:border-gray-600 hover:border-purple-400 hover:bg-gray-50 dark:hover:bg-gray-700/50'
}`}
>
<svg className="w-8 h-8 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 16v-8m0 0l-3 3m3-3l3 3M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2" />
</svg>
<p className="text-xs text-gray-500">
{uploading ? 'Wird hochgeladen...' : 'PDF-Dateien hierher ziehen oder klicken'}
</p>
<p className="text-xs text-gray-400">Max. 50 MB pro Datei</p>
<input
ref={fileInputRef}
type="file"
accept=".pdf"
multiple
className="hidden"
onChange={(e) => { if (e.target.files?.length) uploadFiles(e.target.files); e.target.value = '' }}
/>
</div>
{error && (
<p className="text-xs text-red-600 dark:text-red-400">{error}</p>
)}
{/* File list */}
{loading ? null : docs.length > 0 && (
<div className="space-y-1">
{docs.map((doc) => {
const badge = STATUS_BADGE[doc.status] ?? STATUS_BADGE.uploaded
return (
<div key={doc.id} className="flex items-center gap-3 p-2.5 rounded-lg bg-gray-50 dark:bg-gray-700/50 border border-gray-100 dark:border-gray-600">
<svg className="w-4 h-4 text-red-500 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21h10a2 2 0 002-2V9l-5-5H9a2 2 0 00-2 2v13a2 2 0 002 2z" />
</svg>
<div className="flex-1 min-w-0">
<p className="text-xs font-medium text-gray-900 dark:text-white truncate">{doc.filename}</p>
<p className="text-xs text-gray-400">{formatSize(doc.size_bytes)}</p>
</div>
<span className={`px-2 py-0.5 text-xs rounded-full font-medium ${badge.cls}`}>{badge.label}</span>
<button onClick={() => deleteDoc(doc.id)} className="p-1 text-gray-400 hover:text-red-500 transition-colors" title="Loeschen">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
)
})}
</div>
)}
</>
)}
</div>
)}
</div>
)
}
@@ -2,6 +2,7 @@
import { useParams } from 'next/navigation' import { useParams } from 'next/navigation'
import { SuggestedNorms } from '../_components/SuggestedNorms' import { SuggestedNorms } from '../_components/SuggestedNorms'
import { DocumentUpload } from './_components/DocumentUpload'
export default function NormsPage() { export default function NormsPage() {
const params = useParams() const params = useParams()