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:
@@ -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 { SuggestedNorms } from '../_components/SuggestedNorms'
|
||||
import { DocumentUpload } from './_components/DocumentUpload'
|
||||
|
||||
export default function NormsPage() {
|
||||
const params = useParams()
|
||||
|
||||
Reference in New Issue
Block a user