This repository has been archived on 2026-02-15. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
BreakPilot Dev 19855efacc
Some checks failed
Tests / Go Tests (push) Has been cancelled
Tests / Python Tests (push) Has been cancelled
Tests / Integration Tests (push) Has been cancelled
Tests / Go Lint (push) Has been cancelled
Tests / Python Lint (push) Has been cancelled
Tests / Security Scan (push) Has been cancelled
Tests / All Checks Passed (push) Has been cancelled
Security Scanning / Secret Scanning (push) Has been cancelled
Security Scanning / Dependency Vulnerability Scan (push) Has been cancelled
Security Scanning / Go Security Scan (push) Has been cancelled
Security Scanning / Python Security Scan (push) Has been cancelled
Security Scanning / Node.js Security Scan (push) Has been cancelled
Security Scanning / Docker Image Security (push) Has been cancelled
Security Scanning / Security Summary (push) Has been cancelled
CI/CD Pipeline / Go Tests (push) Has been cancelled
CI/CD Pipeline / Python Tests (push) Has been cancelled
CI/CD Pipeline / Website Tests (push) Has been cancelled
CI/CD Pipeline / Linting (push) Has been cancelled
CI/CD Pipeline / Security Scan (push) Has been cancelled
CI/CD Pipeline / Docker Build & Push (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Deploy to Staging (push) Has been cancelled
CI/CD Pipeline / Deploy to Production (push) Has been cancelled
CI/CD Pipeline / CI Summary (push) Has been cancelled
ci/woodpecker/manual/build-ci-image Pipeline was successful
ci/woodpecker/manual/main Pipeline failed
feat: BreakPilot PWA - Full codebase (clean push without large binaries)
All services: admin-v2, studio-v2, website, ai-compliance-sdk,
consent-service, klausur-service, voice-service, and infrastructure.
Large PDFs and compiled binaries excluded via .gitignore.
2026-02-11 13:25:58 +01:00

426 lines
18 KiB
TypeScript

'use client'
import { useState, useEffect, useCallback } from 'react'
import type {
Collection,
IndexedDocument,
IndexedDocumentsData,
DocumentContent
} from '../types'
const API_BASE = process.env.NEXT_PUBLIC_KLAUSUR_SERVICE_URL || 'http://localhost:8086'
interface DocumentsTabProps {
collections: Collection[]
}
function DocumentsTab({ collections }: DocumentsTabProps) {
const [documents, setDocuments] = useState<IndexedDocumentsData | null>(null)
const [loading, setLoading] = useState(true)
const [selectedCollection, setSelectedCollection] = useState('bp_nibis_eh')
const [yearFilter, setYearFilter] = useState<string>('')
const [subjectFilter, setSubjectFilter] = useState<string>('')
const [currentPage, setCurrentPage] = useState(0)
const [deleting, setDeleting] = useState<string | null>(null)
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null)
const [viewingDoc, setViewingDoc] = useState<DocumentContent | null>(null)
const [loadingContent, setLoadingContent] = useState(false)
const pageSize = 20
const fetchDocuments = useCallback(async () => {
setLoading(true)
try {
const params = new URLSearchParams({
collection: selectedCollection,
limit: pageSize.toString(),
offset: (currentPage * pageSize).toString(),
})
if (yearFilter) params.append('year', yearFilter)
if (subjectFilter) params.append('subject', subjectFilter)
const res = await fetch(`${API_BASE}/api/v1/admin/rag/files/indexed?${params}`)
if (res.ok) {
const data = await res.json()
setDocuments(data)
}
} catch (err) {
console.error('Failed to fetch documents:', err)
} finally {
setLoading(false)
}
}, [selectedCollection, yearFilter, subjectFilter, currentPage])
useEffect(() => {
fetchDocuments()
}, [fetchDocuments])
const handleDelete = async (docId: string, deleteSource: boolean = false) => {
setDeleting(docId)
try {
const params = new URLSearchParams()
if (deleteSource) params.append('delete_source', 'true')
const res = await fetch(
`${API_BASE}/api/v1/admin/rag/files/${selectedCollection}/${encodeURIComponent(docId)}?${params}`,
{ method: 'DELETE' }
)
if (res.ok) {
// Refresh the list
await fetchDocuments()
setDeleteConfirm(null)
}
} catch (err) {
console.error('Failed to delete document:', err)
} finally {
setDeleting(null)
}
}
const handleViewDocument = async (docId: string) => {
setLoadingContent(true)
try {
const res = await fetch(
`${API_BASE}/api/v1/admin/rag/files/${selectedCollection}/${encodeURIComponent(docId)}/content`
)
if (res.ok) {
const data = await res.json()
setViewingDoc(data)
}
} catch (err) {
console.error('Failed to load document content:', err)
} finally {
setLoadingContent(false)
}
}
const handleDownloadPdf = (docId: string) => {
window.open(
`${API_BASE}/api/v1/admin/rag/files/${selectedCollection}/${encodeURIComponent(docId)}/download`,
'_blank'
)
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-slate-900">Indexierte Dokumente</h2>
<p className="text-sm text-slate-500">
Übersicht aller indexierten Dokumente mit Möglichkeit zum Löschen
</p>
</div>
<button
onClick={fetchDocuments}
className="px-4 py-2 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50"
>
Aktualisieren
</button>
</div>
{/* Filters */}
<div className="bg-white rounded-lg border border-slate-200 p-4">
<div className="flex flex-wrap gap-4">
<div className="w-64">
<label className="block text-sm font-medium text-slate-700 mb-1">Sammlung</label>
<select
value={selectedCollection}
onChange={(e) => {
setSelectedCollection(e.target.value)
setCurrentPage(0)
}}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
>
{collections.length > 0 ? (
collections.map((col) => (
<option key={col.name} value={col.name}>
{col.displayName}
</option>
))
) : (
<option value="bp_nibis_eh">Niedersachsen - Klausurkorrektur</option>
)}
</select>
</div>
{documents && (
<>
<div className="w-32">
<label className="block text-sm font-medium text-slate-700 mb-1">Jahr</label>
<select
value={yearFilter}
onChange={(e) => {
setYearFilter(e.target.value)
setCurrentPage(0)
}}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
>
<option value="">Alle Jahre</option>
{documents.years?.map((y) => (
<option key={y} value={y}>{y}</option>
))}
</select>
</div>
<div className="w-48">
<label className="block text-sm font-medium text-slate-700 mb-1">Fach</label>
<select
value={subjectFilter}
onChange={(e) => {
setSubjectFilter(e.target.value)
setCurrentPage(0)
}}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
>
<option value="">Alle Fächer</option>
{documents.subjects?.map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
</div>
</>
)}
</div>
{documents && (
<div className="mt-4 flex items-center gap-6 text-sm text-slate-600">
<span>
<strong>{documents.total_documents ?? documents.totalCount}</strong> Dokumente
</span>
<span>
<strong>{(documents.total_chunks ?? 0).toLocaleString()}</strong> Chunks
</span>
</div>
)}
</div>
{/* Documents List */}
{loading ? (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
</div>
) : documents && documents.documents.length > 0 ? (
<div className="bg-white rounded-lg border border-slate-200 overflow-hidden">
<table className="w-full">
<thead className="bg-slate-50 border-b border-slate-200">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
Jahr
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
Fach
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
Niveau
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
Typ
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
Chunks
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
Vorschau
</th>
<th className="px-4 py-3 text-right text-xs font-medium text-slate-500 uppercase tracking-wider">
Aktionen
</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200">
{documents.documents.map((doc) => (
<tr key={doc.doc_id} className="hover:bg-slate-50">
<td className="px-4 py-3 text-sm font-medium text-slate-900">
{doc.year || '-'}
</td>
<td className="px-4 py-3 text-sm text-slate-700">
{doc.subject || '-'}
</td>
<td className="px-4 py-3 text-sm text-slate-600">
{doc.niveau || '-'}
</td>
<td className="px-4 py-3">
<span className="px-2 py-1 bg-slate-100 text-slate-700 text-xs rounded">
{doc.doc_type || '-'}
</span>
</td>
<td className="px-4 py-3 text-sm text-slate-600 font-mono">
{doc.chunk_count}
</td>
<td className="px-4 py-3 text-xs text-slate-500 max-w-xs truncate">
{doc.sample_text || '-'}
</td>
<td className="px-4 py-3 text-right">
{deleteConfirm === doc.doc_id ? (
<div className="flex items-center justify-end gap-2">
<button
onClick={() => handleDelete(doc.doc_id, false)}
disabled={deleting === doc.doc_id}
className="px-2 py-1 text-xs text-red-700 bg-red-100 rounded hover:bg-red-200"
>
Nur Index
</button>
<button
onClick={() => handleDelete(doc.doc_id, true)}
disabled={deleting === doc.doc_id}
className="px-2 py-1 text-xs text-red-900 bg-red-200 rounded hover:bg-red-300"
>
+ Datei
</button>
<button
onClick={() => setDeleteConfirm(null)}
className="px-2 py-1 text-xs text-slate-600 bg-slate-100 rounded hover:bg-slate-200"
>
Abbrechen
</button>
</div>
) : (
<div className="flex items-center justify-end gap-1">
<button
onClick={() => handleViewDocument(doc.doc_id)}
className="p-1 text-slate-400 hover:text-primary-600"
title="Dokument anzeigen"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
</button>
<button
onClick={() => handleDownloadPdf(doc.doc_id)}
className="p-1 text-slate-400 hover:text-blue-600"
title="PDF herunterladen"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 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" />
</svg>
</button>
<button
onClick={() => setDeleteConfirm(doc.doc_id)}
className="p-1 text-slate-400 hover:text-red-600"
title="Dokument löschen"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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>
)}
</td>
</tr>
))}
</tbody>
</table>
{/* Pagination */}
{documents.pagination && (
<div className="px-4 py-3 border-t border-slate-200 flex items-center justify-between bg-slate-50">
<span className="text-sm text-slate-600">
Zeige {documents.pagination.offset + 1} bis{' '}
{Math.min(documents.pagination.offset + pageSize, documents.total_documents ?? documents.totalCount)} von{' '}
{documents.total_documents ?? documents.totalCount}
</span>
<div className="flex gap-2">
<button
onClick={() => setCurrentPage((p) => Math.max(0, p - 1))}
disabled={currentPage === 0}
className="px-3 py-1 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Zurück
</button>
<button
onClick={() => setCurrentPage((p) => p + 1)}
disabled={!documents.pagination.has_more}
className="px-3 py-1 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Weiter
</button>
</div>
</div>
)}
</div>
) : (
<div className="bg-slate-50 rounded-lg p-8 text-center">
<svg className="w-12 h-12 text-slate-400 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="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" />
</svg>
<h3 className="text-lg font-medium text-slate-900 mb-2">Keine Dokumente gefunden</h3>
<p className="text-slate-500">
{yearFilter || subjectFilter
? 'Keine Dokumente mit den gewählten Filtern.'
: 'Diese Sammlung enthält noch keine indexierten Dokumente.'}
</p>
</div>
)}
{/* Document Viewer Modal */}
{(viewingDoc || loadingContent) && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-xl shadow-xl w-full max-w-4xl max-h-[90vh] flex flex-col">
{/* Header */}
<div className="p-4 border-b border-slate-200 flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-slate-900">
{viewingDoc?.metadata?.filename || 'Dokument'}
</h3>
{viewingDoc?.metadata && (
<div className="flex items-center gap-3 mt-1 text-sm text-slate-500">
{viewingDoc.metadata.year && <span>{viewingDoc.metadata.year}</span>}
{viewingDoc.metadata.subject && <span>{viewingDoc.metadata.subject}</span>}
{viewingDoc.metadata.niveau && <span>{viewingDoc.metadata.niveau}</span>}
<span>{viewingDoc?.chunk_count} Chunks</span>
</div>
)}
</div>
<div className="flex items-center gap-2">
{viewingDoc && (
<button
onClick={() => handleDownloadPdf(viewingDoc.doc_id)}
className="px-3 py-1.5 text-sm font-medium text-blue-700 bg-blue-50 rounded-lg hover:bg-blue-100 flex items-center gap-1"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 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" />
</svg>
PDF
</button>
)}
<button
onClick={() => setViewingDoc(null)}
className="p-1.5 text-slate-400 hover:text-slate-600 rounded-lg hover:bg-slate-100"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-6">
{loadingContent ? (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
</div>
) : viewingDoc ? (
<div className="prose prose-slate max-w-none">
<pre className="whitespace-pre-wrap font-sans text-sm text-slate-700 bg-slate-50 p-4 rounded-lg">
{viewingDoc.text}
</pre>
</div>
) : null}
</div>
</div>
</div>
)}
</div>
)
}
// ============================================================================
// Search Tab
// ============================================================================
export { DocumentsTab }