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>
582 lines
23 KiB
TypeScript
582 lines
23 KiB
TypeScript
'use client'
|
|
|
|
/**
|
|
* Staff Search Admin Page
|
|
*
|
|
* Search and browse university staff members and their publications.
|
|
* Potential customers for BreakPilot services.
|
|
*/
|
|
|
|
import { useState, useEffect, useCallback } from 'react'
|
|
import AdminLayout from '@/components/admin/AdminLayout'
|
|
|
|
interface StaffMember {
|
|
id: string
|
|
first_name?: string
|
|
last_name: string
|
|
full_name?: string
|
|
title?: string
|
|
position?: string
|
|
position_type?: string
|
|
is_professor: boolean
|
|
email?: string
|
|
profile_url?: string
|
|
photo_url?: string
|
|
orcid?: string
|
|
research_interests?: string[]
|
|
university_name?: string
|
|
university_short?: string
|
|
department_name?: string
|
|
publication_count: number
|
|
}
|
|
|
|
interface Publication {
|
|
id: string
|
|
title: string
|
|
abstract?: string
|
|
year?: number
|
|
pub_type?: string
|
|
venue?: string
|
|
doi?: string
|
|
url?: string
|
|
citation_count: number
|
|
}
|
|
|
|
interface StaffStats {
|
|
total_staff: number
|
|
total_professors: number
|
|
total_publications: number
|
|
total_universities: number
|
|
by_state?: Record<string, number>
|
|
by_uni_type?: Record<string, number>
|
|
by_position_type?: Record<string, number>
|
|
}
|
|
|
|
interface University {
|
|
id: string
|
|
name: string
|
|
short_name?: string
|
|
url: string
|
|
state?: string
|
|
uni_type?: string
|
|
}
|
|
|
|
const EDU_SEARCH_API = process.env.NEXT_PUBLIC_EDU_SEARCH_URL || 'http://localhost:8086'
|
|
|
|
export default function StaffSearchPage() {
|
|
const [searchQuery, setSearchQuery] = useState('')
|
|
const [staff, setStaff] = useState<StaffMember[]>([])
|
|
const [selectedStaff, setSelectedStaff] = useState<StaffMember | null>(null)
|
|
const [publications, setPublications] = useState<Publication[]>([])
|
|
const [stats, setStats] = useState<StaffStats | null>(null)
|
|
const [universities, setUniversities] = useState<University[]>([])
|
|
const [loading, setLoading] = useState(false)
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
// Filters
|
|
const [filterState, setFilterState] = useState('')
|
|
const [filterUniType, setFilterUniType] = useState('')
|
|
const [filterPositionType, setFilterPositionType] = useState('')
|
|
const [filterProfessorsOnly, setFilterProfessorsOnly] = useState(false)
|
|
|
|
// Pagination
|
|
const [total, setTotal] = useState(0)
|
|
const [offset, setOffset] = useState(0)
|
|
const limit = 20
|
|
|
|
// Fetch stats on mount
|
|
useEffect(() => {
|
|
fetchStats()
|
|
fetchUniversities()
|
|
}, [])
|
|
|
|
const fetchStats = async () => {
|
|
try {
|
|
const res = await fetch(`${EDU_SEARCH_API}/api/v1/staff/stats`)
|
|
if (res.ok) {
|
|
const data = await res.json()
|
|
setStats(data)
|
|
}
|
|
} catch {
|
|
// Stats not critical
|
|
}
|
|
}
|
|
|
|
const fetchUniversities = async () => {
|
|
try {
|
|
const res = await fetch(`${EDU_SEARCH_API}/api/v1/universities`)
|
|
if (res.ok) {
|
|
const data = await res.json()
|
|
setUniversities(data.universities || [])
|
|
}
|
|
} catch {
|
|
// Universities not critical
|
|
}
|
|
}
|
|
|
|
const searchStaff = useCallback(async (newOffset = 0) => {
|
|
setLoading(true)
|
|
setError(null)
|
|
|
|
try {
|
|
const params = new URLSearchParams()
|
|
if (searchQuery) params.append('q', searchQuery)
|
|
if (filterState) params.append('state', filterState)
|
|
if (filterUniType) params.append('uni_type', filterUniType)
|
|
if (filterPositionType) params.append('position_type', filterPositionType)
|
|
if (filterProfessorsOnly) params.append('is_professor', 'true')
|
|
params.append('limit', limit.toString())
|
|
params.append('offset', newOffset.toString())
|
|
|
|
const res = await fetch(`${EDU_SEARCH_API}/api/v1/staff/search?${params}`)
|
|
if (!res.ok) throw new Error('Search failed')
|
|
|
|
const data = await res.json()
|
|
setStaff(data.staff || [])
|
|
setTotal(data.total || 0)
|
|
setOffset(newOffset)
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Search failed')
|
|
setStaff([])
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [searchQuery, filterState, filterUniType, filterPositionType, filterProfessorsOnly])
|
|
|
|
// Search on filter change
|
|
useEffect(() => {
|
|
const timer = setTimeout(() => {
|
|
searchStaff(0)
|
|
}, 300)
|
|
return () => clearTimeout(timer)
|
|
}, [searchStaff])
|
|
|
|
const fetchPublications = async (staffId: string) => {
|
|
try {
|
|
const res = await fetch(`${EDU_SEARCH_API}/api/v1/staff/${staffId}/publications`)
|
|
if (res.ok) {
|
|
const data = await res.json()
|
|
setPublications(data.publications || [])
|
|
}
|
|
} catch {
|
|
setPublications([])
|
|
}
|
|
}
|
|
|
|
const handleSelectStaff = (member: StaffMember) => {
|
|
setSelectedStaff(member)
|
|
fetchPublications(member.id)
|
|
}
|
|
|
|
const getPositionBadgeColor = (posType?: string) => {
|
|
switch (posType) {
|
|
case 'professor':
|
|
return 'bg-purple-100 text-purple-800'
|
|
case 'postdoc':
|
|
return 'bg-blue-100 text-blue-800'
|
|
case 'researcher':
|
|
return 'bg-green-100 text-green-800'
|
|
case 'phd_student':
|
|
return 'bg-yellow-100 text-yellow-800'
|
|
default:
|
|
return 'bg-gray-100 text-gray-800'
|
|
}
|
|
}
|
|
|
|
const getStateBadgeColor = (state?: string) => {
|
|
const colors: Record<string, string> = {
|
|
BW: 'bg-yellow-100 text-yellow-800',
|
|
BY: 'bg-blue-100 text-blue-800',
|
|
BE: 'bg-red-100 text-red-800',
|
|
NW: 'bg-green-100 text-green-800',
|
|
HE: 'bg-orange-100 text-orange-800',
|
|
SN: 'bg-purple-100 text-purple-800',
|
|
}
|
|
return colors[state || ''] || 'bg-gray-100 text-gray-800'
|
|
}
|
|
|
|
return (
|
|
<AdminLayout
|
|
title="Personensuche"
|
|
description="Universitatsmitarbeiter & Publikationen - Potentielle Kunden"
|
|
>
|
|
{/* Stats Overview */}
|
|
{stats && (
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
|
<div className="bg-white p-4 rounded-lg shadow-sm border">
|
|
<div className="text-2xl font-bold text-primary-600">{stats.total_staff.toLocaleString()}</div>
|
|
<div className="text-sm text-slate-500">Mitarbeiter gesamt</div>
|
|
</div>
|
|
<div className="bg-white p-4 rounded-lg shadow-sm border">
|
|
<div className="text-2xl font-bold text-purple-600">{stats.total_professors.toLocaleString()}</div>
|
|
<div className="text-sm text-slate-500">Professoren</div>
|
|
</div>
|
|
<div className="bg-white p-4 rounded-lg shadow-sm border">
|
|
<div className="text-2xl font-bold text-green-600">{stats.total_publications.toLocaleString()}</div>
|
|
<div className="text-sm text-slate-500">Publikationen</div>
|
|
</div>
|
|
<div className="bg-white p-4 rounded-lg shadow-sm border">
|
|
<div className="text-2xl font-bold text-blue-600">{stats.total_universities}</div>
|
|
<div className="text-sm text-slate-500">Universitaten</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex gap-6">
|
|
{/* Left Panel: Search & Results */}
|
|
<div className="flex-1">
|
|
{/* Search Bar */}
|
|
<div className="bg-white p-4 rounded-lg shadow-sm border mb-4">
|
|
<div className="flex gap-4 mb-4">
|
|
<div className="flex-1">
|
|
<input
|
|
type="text"
|
|
placeholder="Suche nach Name, Forschungsgebiet..."
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
className="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
|
/>
|
|
</div>
|
|
<button
|
|
onClick={() => searchStaff(0)}
|
|
disabled={loading}
|
|
className="px-6 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50"
|
|
>
|
|
{loading ? 'Suche...' : 'Suchen'}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Filters */}
|
|
<div className="flex flex-wrap gap-4">
|
|
<select
|
|
value={filterState}
|
|
onChange={(e) => setFilterState(e.target.value)}
|
|
className="px-3 py-1.5 border rounded-lg text-sm"
|
|
>
|
|
<option value="">Alle Bundeslander</option>
|
|
<option value="BW">Baden-Wurttemberg</option>
|
|
<option value="BY">Bayern</option>
|
|
<option value="BE">Berlin</option>
|
|
<option value="BB">Brandenburg</option>
|
|
<option value="HB">Bremen</option>
|
|
<option value="HH">Hamburg</option>
|
|
<option value="HE">Hessen</option>
|
|
<option value="MV">Mecklenburg-Vorpommern</option>
|
|
<option value="NI">Niedersachsen</option>
|
|
<option value="NW">Nordrhein-Westfalen</option>
|
|
<option value="RP">Rheinland-Pfalz</option>
|
|
<option value="SL">Saarland</option>
|
|
<option value="SN">Sachsen</option>
|
|
<option value="ST">Sachsen-Anhalt</option>
|
|
<option value="SH">Schleswig-Holstein</option>
|
|
<option value="TH">Thuringen</option>
|
|
</select>
|
|
|
|
<select
|
|
value={filterUniType}
|
|
onChange={(e) => setFilterUniType(e.target.value)}
|
|
className="px-3 py-1.5 border rounded-lg text-sm"
|
|
>
|
|
<option value="">Alle Hochschultypen</option>
|
|
<option value="UNI">Universitaten</option>
|
|
<option value="FH">Fachhochschulen</option>
|
|
<option value="PH">Pad. Hochschulen</option>
|
|
<option value="KUNST">Kunsthochschulen</option>
|
|
<option value="PRIVATE">Private</option>
|
|
</select>
|
|
|
|
<select
|
|
value={filterPositionType}
|
|
onChange={(e) => setFilterPositionType(e.target.value)}
|
|
className="px-3 py-1.5 border rounded-lg text-sm"
|
|
>
|
|
<option value="">Alle Positionen</option>
|
|
<option value="professor">Professoren</option>
|
|
<option value="postdoc">Postdocs</option>
|
|
<option value="researcher">Wissenschaftler</option>
|
|
<option value="phd_student">Doktoranden</option>
|
|
<option value="staff">Sonstige</option>
|
|
</select>
|
|
|
|
<label className="flex items-center gap-2 text-sm">
|
|
<input
|
|
type="checkbox"
|
|
checked={filterProfessorsOnly}
|
|
onChange={(e) => setFilterProfessorsOnly(e.target.checked)}
|
|
className="rounded border-gray-300"
|
|
/>
|
|
Nur Professoren
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Error */}
|
|
{error && (
|
|
<div className="bg-red-50 text-red-700 p-4 rounded-lg mb-4">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{/* Results */}
|
|
<div className="bg-white rounded-lg shadow-sm border">
|
|
<div className="p-4 border-b flex justify-between items-center">
|
|
<span className="font-medium">
|
|
{total > 0 ? `${total.toLocaleString()} Ergebnisse` : 'Keine Ergebnisse'}
|
|
</span>
|
|
{total > limit && (
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={() => searchStaff(Math.max(0, offset - limit))}
|
|
disabled={offset === 0 || loading}
|
|
className="px-3 py-1 text-sm border rounded hover:bg-gray-50 disabled:opacity-50"
|
|
>
|
|
Zuruck
|
|
</button>
|
|
<span className="px-3 py-1 text-sm text-gray-500">
|
|
{Math.floor(offset / limit) + 1} / {Math.ceil(total / limit)}
|
|
</span>
|
|
<button
|
|
onClick={() => searchStaff(offset + limit)}
|
|
disabled={offset + limit >= total || loading}
|
|
className="px-3 py-1 text-sm border rounded hover:bg-gray-50 disabled:opacity-50"
|
|
>
|
|
Weiter
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="divide-y">
|
|
{staff.map((member) => (
|
|
<div
|
|
key={member.id}
|
|
onClick={() => handleSelectStaff(member)}
|
|
className={`p-4 cursor-pointer hover:bg-gray-50 transition-colors ${
|
|
selectedStaff?.id === member.id ? 'bg-primary-50' : ''
|
|
}`}
|
|
>
|
|
<div className="flex items-start gap-4">
|
|
{member.photo_url ? (
|
|
<img
|
|
src={member.photo_url}
|
|
alt={member.full_name || member.last_name}
|
|
className="w-12 h-12 rounded-full object-cover"
|
|
/>
|
|
) : (
|
|
<div className="w-12 h-12 rounded-full bg-slate-200 flex items-center justify-center text-slate-500 font-medium">
|
|
{(member.first_name?.[0] || '') + (member.last_name?.[0] || '')}
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-medium text-slate-900">
|
|
{member.title && `${member.title} `}
|
|
{member.full_name || `${member.first_name || ''} ${member.last_name}`}
|
|
</span>
|
|
{member.is_professor && (
|
|
<span className="px-2 py-0.5 text-xs rounded-full bg-purple-100 text-purple-800">
|
|
Prof
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
<div className="text-sm text-slate-500 truncate">
|
|
{member.position || member.position_type}
|
|
{member.department_name && ` - ${member.department_name}`}
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2 mt-1">
|
|
<span className="text-xs text-slate-400">
|
|
{member.university_short || member.university_name}
|
|
</span>
|
|
{member.publication_count > 0 && (
|
|
<span className="text-xs text-green-600">
|
|
{member.publication_count} Publikationen
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-col items-end gap-1">
|
|
{member.position_type && (
|
|
<span className={`px-2 py-0.5 text-xs rounded-full ${getPositionBadgeColor(member.position_type)}`}>
|
|
{member.position_type}
|
|
</span>
|
|
)}
|
|
{member.email && (
|
|
<a
|
|
href={`mailto:${member.email}`}
|
|
onClick={(e) => e.stopPropagation()}
|
|
className="text-xs text-primary-600 hover:underline"
|
|
>
|
|
E-Mail
|
|
</a>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{member.research_interests && member.research_interests.length > 0 && (
|
|
<div className="mt-2 flex flex-wrap gap-1">
|
|
{member.research_interests.slice(0, 5).map((interest, i) => (
|
|
<span key={i} className="px-2 py-0.5 text-xs bg-slate-100 rounded-full">
|
|
{interest}
|
|
</span>
|
|
))}
|
|
{member.research_interests.length > 5 && (
|
|
<span className="px-2 py-0.5 text-xs text-slate-400">
|
|
+{member.research_interests.length - 5} mehr
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
|
|
{staff.length === 0 && !loading && (
|
|
<div className="p-8 text-center text-slate-500">
|
|
{searchQuery || filterState || filterUniType || filterPositionType
|
|
? 'Keine Ergebnisse fur diese Suche'
|
|
: 'Geben Sie einen Suchbegriff ein oder wahlen Sie Filter'}
|
|
</div>
|
|
)}
|
|
|
|
{loading && (
|
|
<div className="p-8 text-center text-slate-500">
|
|
Suche lauft...
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Right Panel: Detail View */}
|
|
<div className="w-96 shrink-0">
|
|
<div className="bg-white rounded-lg shadow-sm border sticky top-20">
|
|
{selectedStaff ? (
|
|
<div>
|
|
{/* Header */}
|
|
<div className="p-4 border-b">
|
|
<div className="flex items-start gap-4">
|
|
{selectedStaff.photo_url ? (
|
|
<img
|
|
src={selectedStaff.photo_url}
|
|
alt={selectedStaff.full_name || selectedStaff.last_name}
|
|
className="w-16 h-16 rounded-full object-cover"
|
|
/>
|
|
) : (
|
|
<div className="w-16 h-16 rounded-full bg-slate-200 flex items-center justify-center text-slate-500 text-xl font-medium">
|
|
{(selectedStaff.first_name?.[0] || '') + (selectedStaff.last_name?.[0] || '')}
|
|
</div>
|
|
)}
|
|
<div>
|
|
<h3 className="font-semibold text-lg">
|
|
{selectedStaff.title && `${selectedStaff.title} `}
|
|
{selectedStaff.full_name || `${selectedStaff.first_name || ''} ${selectedStaff.last_name}`}
|
|
</h3>
|
|
<p className="text-sm text-slate-500">{selectedStaff.position}</p>
|
|
<p className="text-sm text-slate-400">{selectedStaff.university_name}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Contact */}
|
|
<div className="p-4 border-b space-y-2">
|
|
{selectedStaff.email && (
|
|
<div className="flex items-center gap-2 text-sm">
|
|
<svg className="w-4 h-4 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
|
</svg>
|
|
<a href={`mailto:${selectedStaff.email}`} className="text-primary-600 hover:underline">
|
|
{selectedStaff.email}
|
|
</a>
|
|
</div>
|
|
)}
|
|
{selectedStaff.profile_url && (
|
|
<div className="flex items-center gap-2 text-sm">
|
|
<svg className="w-4 h-4 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
|
|
</svg>
|
|
<a href={selectedStaff.profile_url} target="_blank" rel="noopener noreferrer" className="text-primary-600 hover:underline truncate">
|
|
Profil
|
|
</a>
|
|
</div>
|
|
)}
|
|
{selectedStaff.orcid && (
|
|
<div className="flex items-center gap-2 text-sm">
|
|
<span className="w-4 h-4 text-green-600 font-bold text-xs">ID</span>
|
|
<a href={`https://orcid.org/${selectedStaff.orcid}`} target="_blank" rel="noopener noreferrer" className="text-primary-600 hover:underline">
|
|
ORCID: {selectedStaff.orcid}
|
|
</a>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Research Interests */}
|
|
{selectedStaff.research_interests && selectedStaff.research_interests.length > 0 && (
|
|
<div className="p-4 border-b">
|
|
<h4 className="text-sm font-medium text-slate-700 mb-2">Forschungsgebiete</h4>
|
|
<div className="flex flex-wrap gap-1">
|
|
{selectedStaff.research_interests.map((interest, i) => (
|
|
<span key={i} className="px-2 py-0.5 text-xs bg-blue-50 text-blue-700 rounded-full">
|
|
{interest}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Publications */}
|
|
<div className="p-4">
|
|
<h4 className="text-sm font-medium text-slate-700 mb-2">
|
|
Publikationen ({publications.length})
|
|
</h4>
|
|
{publications.length > 0 ? (
|
|
<div className="space-y-3 max-h-64 overflow-y-auto">
|
|
{publications.map((pub) => (
|
|
<div key={pub.id} className="text-sm">
|
|
<p className="font-medium text-slate-800 line-clamp-2">{pub.title}</p>
|
|
<div className="flex items-center gap-2 text-xs text-slate-500 mt-1">
|
|
{pub.year && <span>{pub.year}</span>}
|
|
{pub.venue && <span>| {pub.venue}</span>}
|
|
{pub.citation_count > 0 && (
|
|
<span className="text-green-600">{pub.citation_count} Zitierungen</span>
|
|
)}
|
|
</div>
|
|
{pub.doi && (
|
|
<a
|
|
href={`https://doi.org/${pub.doi}`}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-xs text-primary-600 hover:underline"
|
|
>
|
|
DOI
|
|
</a>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<p className="text-sm text-slate-400">Keine Publikationen gefunden</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<div className="p-4 border-t bg-slate-50">
|
|
<button className="w-full px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 text-sm">
|
|
Als Kunde markieren
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="p-8 text-center text-slate-400">
|
|
Wahlen Sie eine Person aus der Liste
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</AdminLayout>
|
|
)
|
|
}
|