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 { 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()
|
||||||
|
|||||||
Reference in New Issue
Block a user