Services: Admin-Lehrer, Backend-Lehrer, Studio v2, Website, Klausur-Service, School-Service, Voice-Service, Geo-Service, BreakPilot Drive, Agent-Core Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
367 lines
15 KiB
TypeScript
367 lines
15 KiB
TypeScript
'use client'
|
|
|
|
/**
|
|
* Dokumente Tab for edu-search page
|
|
* Shows filterable list of Abitur documents with pagination
|
|
*/
|
|
|
|
import { useState, useEffect } from 'react'
|
|
import { FileText, Filter, ChevronLeft, ChevronRight, Eye, Download, Search, X, Loader2 } from 'lucide-react'
|
|
import {
|
|
AbiturDokument,
|
|
AbiturDocsResponse,
|
|
formatFileSize,
|
|
formatDocumentTitle,
|
|
FAECHER,
|
|
JAHRE,
|
|
BUNDESLAENDER,
|
|
NIVEAUS,
|
|
TYPEN,
|
|
} from '@/lib/education/abitur-docs-types'
|
|
import { PDFPreviewModal } from './PDFPreviewModal'
|
|
|
|
interface DokumenteTabProps {
|
|
onDocumentCountChange?: (count: number) => void
|
|
}
|
|
|
|
export function DokumenteTab({ onDocumentCountChange }: DokumenteTabProps) {
|
|
const [documents, setDocuments] = useState<AbiturDokument[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
// Pagination
|
|
const [page, setPage] = useState(1)
|
|
const [totalPages, setTotalPages] = useState(1)
|
|
const [total, setTotal] = useState(0)
|
|
const limit = 20
|
|
|
|
// Filters
|
|
const [filterOpen, setFilterOpen] = useState(false)
|
|
const [filterFach, setFilterFach] = useState<string>('')
|
|
const [filterJahr, setFilterJahr] = useState<string>('')
|
|
const [filterBundesland, setFilterBundesland] = useState<string>('')
|
|
const [filterNiveau, setFilterNiveau] = useState<string>('')
|
|
const [filterTyp, setFilterTyp] = useState<string>('')
|
|
|
|
// Modal
|
|
const [selectedDocument, setSelectedDocument] = useState<AbiturDokument | null>(null)
|
|
|
|
// Fetch documents
|
|
useEffect(() => {
|
|
const fetchDocuments = async () => {
|
|
setLoading(true)
|
|
setError(null)
|
|
|
|
const params = new URLSearchParams()
|
|
params.set('page', page.toString())
|
|
params.set('limit', limit.toString())
|
|
if (filterFach) params.set('fach', filterFach)
|
|
if (filterJahr) params.set('jahr', filterJahr)
|
|
if (filterBundesland) params.set('bundesland', filterBundesland)
|
|
if (filterNiveau) params.set('niveau', filterNiveau)
|
|
if (filterTyp) params.set('typ', filterTyp)
|
|
|
|
try {
|
|
const response = await fetch(`/api/education/abitur-docs?${params.toString()}`)
|
|
if (!response.ok) throw new Error('Fehler beim Laden der Dokumente')
|
|
|
|
const data: AbiturDocsResponse = await response.json()
|
|
setDocuments(data.documents || [])
|
|
setTotalPages(data.total_pages || 1)
|
|
setTotal(data.total || 0)
|
|
onDocumentCountChange?.(data.total || 0)
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
fetchDocuments()
|
|
}, [page, filterFach, filterJahr, filterBundesland, filterNiveau, filterTyp, onDocumentCountChange])
|
|
|
|
const clearFilters = () => {
|
|
setFilterFach('')
|
|
setFilterJahr('')
|
|
setFilterBundesland('')
|
|
setFilterNiveau('')
|
|
setFilterTyp('')
|
|
setPage(1)
|
|
}
|
|
|
|
const hasActiveFilters = filterFach || filterJahr || filterBundesland || filterNiveau || filterTyp
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* Filter Bar */}
|
|
<div className="bg-white rounded-xl border border-slate-200 p-4">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<button
|
|
onClick={() => setFilterOpen(!filterOpen)}
|
|
className={`px-4 py-2 rounded-lg font-medium flex items-center gap-2 transition-colors ${
|
|
filterOpen || hasActiveFilters
|
|
? 'bg-purple-100 text-purple-700'
|
|
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
|
}`}
|
|
>
|
|
<Filter className="w-4 h-4" />
|
|
Filter
|
|
{hasActiveFilters && (
|
|
<span className="bg-purple-600 text-white text-xs px-1.5 py-0.5 rounded-full">
|
|
{[filterFach, filterJahr, filterBundesland, filterNiveau, filterTyp].filter(Boolean).length}
|
|
</span>
|
|
)}
|
|
</button>
|
|
|
|
{hasActiveFilters && (
|
|
<button
|
|
onClick={clearFilters}
|
|
className="text-sm text-slate-500 hover:text-slate-700 flex items-center gap-1"
|
|
>
|
|
<X className="w-4 h-4" />
|
|
Filter zurücksetzen
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Filter Dropdowns */}
|
|
{filterOpen && (
|
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-3 pt-4 border-t border-slate-200">
|
|
{/* Fach */}
|
|
<div>
|
|
<label className="block text-xs text-slate-500 mb-1">Fach</label>
|
|
<select
|
|
value={filterFach}
|
|
onChange={(e) => { setFilterFach(e.target.value); setPage(1) }}
|
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500"
|
|
>
|
|
<option value="">Alle Fächer</option>
|
|
{FAECHER.map(f => (
|
|
<option key={f.id} value={f.id}>{f.label}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
{/* Jahr */}
|
|
<div>
|
|
<label className="block text-xs text-slate-500 mb-1">Jahr</label>
|
|
<select
|
|
value={filterJahr}
|
|
onChange={(e) => { setFilterJahr(e.target.value); setPage(1) }}
|
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500"
|
|
>
|
|
<option value="">Alle Jahre</option>
|
|
{JAHRE.map(j => (
|
|
<option key={j} value={j}>{j}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
{/* Bundesland */}
|
|
<div>
|
|
<label className="block text-xs text-slate-500 mb-1">Bundesland</label>
|
|
<select
|
|
value={filterBundesland}
|
|
onChange={(e) => { setFilterBundesland(e.target.value); setPage(1) }}
|
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500"
|
|
>
|
|
<option value="">Alle Bundesländer</option>
|
|
{BUNDESLAENDER.map(b => (
|
|
<option key={b.id} value={b.id}>{b.label}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
{/* Niveau */}
|
|
<div>
|
|
<label className="block text-xs text-slate-500 mb-1">Niveau</label>
|
|
<select
|
|
value={filterNiveau}
|
|
onChange={(e) => { setFilterNiveau(e.target.value); setPage(1) }}
|
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500"
|
|
>
|
|
<option value="">Alle Niveaus</option>
|
|
{NIVEAUS.map(n => (
|
|
<option key={n.id} value={n.id}>{n.label}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
{/* Typ */}
|
|
<div>
|
|
<label className="block text-xs text-slate-500 mb-1">Typ</label>
|
|
<select
|
|
value={filterTyp}
|
|
onChange={(e) => { setFilterTyp(e.target.value); setPage(1) }}
|
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500"
|
|
>
|
|
<option value="">Alle Typen</option>
|
|
{TYPEN.map(t => (
|
|
<option key={t.id} value={t.id}>{t.label}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Document List */}
|
|
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
|
{loading ? (
|
|
<div className="flex items-center justify-center py-12">
|
|
<Loader2 className="w-8 h-8 text-blue-600 animate-spin" />
|
|
</div>
|
|
) : error ? (
|
|
<div className="text-center py-12 text-red-600">
|
|
<p>{error}</p>
|
|
<button
|
|
onClick={() => setPage(1)}
|
|
className="mt-2 text-sm text-blue-600 hover:underline"
|
|
>
|
|
Erneut versuchen
|
|
</button>
|
|
</div>
|
|
) : documents.length === 0 ? (
|
|
<div className="text-center py-12 text-slate-500">
|
|
<FileText className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
|
<p>Keine Dokumente gefunden</p>
|
|
{hasActiveFilters && (
|
|
<button
|
|
onClick={clearFilters}
|
|
className="mt-2 text-sm text-blue-600 hover:underline"
|
|
>
|
|
Filter zurücksetzen
|
|
</button>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<>
|
|
<table className="w-full text-sm">
|
|
<thead className="bg-slate-50 border-b border-slate-200">
|
|
<tr>
|
|
<th className="text-left px-4 py-3 font-medium text-slate-600">Dokument</th>
|
|
<th className="text-left px-4 py-3 font-medium text-slate-600">Fach</th>
|
|
<th className="text-center px-4 py-3 font-medium text-slate-600">Jahr</th>
|
|
<th className="text-center px-4 py-3 font-medium text-slate-600">Niveau</th>
|
|
<th className="text-center px-4 py-3 font-medium text-slate-600">Typ</th>
|
|
<th className="text-right px-4 py-3 font-medium text-slate-600">Groesse</th>
|
|
<th className="text-center px-4 py-3 font-medium text-slate-600">Status</th>
|
|
<th className="text-center px-4 py-3 font-medium text-slate-600">Aktion</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{documents.map((doc) => {
|
|
const fachLabel = FAECHER.find(f => f.id === doc.fach)?.label || doc.fach
|
|
return (
|
|
<tr
|
|
key={doc.id}
|
|
className="border-b border-slate-100 hover:bg-slate-50 cursor-pointer"
|
|
onClick={() => setSelectedDocument(doc)}
|
|
>
|
|
<td className="px-4 py-3">
|
|
<div className="flex items-center gap-2">
|
|
<FileText className="w-4 h-4 text-red-500" />
|
|
<span className="font-medium text-slate-900 truncate max-w-[200px]" title={doc.dateiname}>
|
|
{doc.dateiname}
|
|
</span>
|
|
</div>
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<span className="capitalize">{fachLabel}</span>
|
|
</td>
|
|
<td className="px-4 py-3 text-center">{doc.jahr}</td>
|
|
<td className="px-4 py-3 text-center">
|
|
<span className={`px-2 py-0.5 rounded-full text-xs ${
|
|
doc.niveau === 'eA'
|
|
? 'bg-blue-100 text-blue-700'
|
|
: 'bg-slate-100 text-slate-700'
|
|
}`}>
|
|
{doc.niveau}
|
|
</span>
|
|
</td>
|
|
<td className="px-4 py-3 text-center">
|
|
<span className={`px-2 py-0.5 rounded-full text-xs ${
|
|
doc.typ === 'erwartungshorizont'
|
|
? 'bg-orange-100 text-orange-700'
|
|
: 'bg-purple-100 text-purple-700'
|
|
}`}>
|
|
{doc.typ === 'erwartungshorizont' ? 'EWH' : 'Aufgabe'}
|
|
</span>
|
|
</td>
|
|
<td className="px-4 py-3 text-right text-slate-500">
|
|
{formatFileSize(doc.file_size)}
|
|
</td>
|
|
<td className="px-4 py-3 text-center">
|
|
<span className={`px-2 py-0.5 rounded-full text-xs ${
|
|
doc.status === 'indexed'
|
|
? 'bg-green-100 text-green-700'
|
|
: doc.status === 'error'
|
|
? 'bg-red-100 text-red-700'
|
|
: 'bg-yellow-100 text-yellow-700'
|
|
}`}>
|
|
{doc.status === 'indexed' ? 'Indexiert' : doc.status === 'error' ? 'Fehler' : 'Ausstehend'}
|
|
</span>
|
|
</td>
|
|
<td className="px-4 py-3 text-center">
|
|
<div className="flex items-center justify-center gap-1" onClick={(e) => e.stopPropagation()}>
|
|
<button
|
|
onClick={() => setSelectedDocument(doc)}
|
|
className="p-1.5 text-blue-600 hover:bg-blue-50 rounded"
|
|
title="Vorschau"
|
|
>
|
|
<Eye className="w-4 h-4" />
|
|
</button>
|
|
<a
|
|
href={doc.file_path}
|
|
download={doc.dateiname}
|
|
className="p-1.5 text-slate-600 hover:bg-slate-100 rounded"
|
|
title="Download"
|
|
>
|
|
<Download className="w-4 h-4" />
|
|
</a>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
)
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
|
|
{/* Pagination */}
|
|
<div className="flex items-center justify-between px-4 py-3 border-t border-slate-200 bg-slate-50">
|
|
<div className="text-sm text-slate-500">
|
|
Zeige {(page - 1) * limit + 1}-{Math.min(page * limit, total)} von {total} Dokumenten
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={() => setPage(p => Math.max(1, p - 1))}
|
|
disabled={page === 1}
|
|
className="p-2 rounded-lg hover:bg-slate-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
<ChevronLeft className="w-4 h-4" />
|
|
</button>
|
|
<span className="text-sm text-slate-600">
|
|
Seite {page} von {totalPages}
|
|
</span>
|
|
<button
|
|
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
|
disabled={page === totalPages}
|
|
className="p-2 rounded-lg hover:bg-slate-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
<ChevronRight className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* PDF Preview Modal */}
|
|
<PDFPreviewModal
|
|
document={selectedDocument}
|
|
onClose={() => setSelectedDocument(null)}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|