[split-required] Split remaining 500-680 LOC files (final batch)

website (17 pages + 3 components):
- multiplayer/wizard, middleware/wizard+test-wizard, communication
- builds/wizard, staff-search, voice, sbom/wizard
- foerderantrag, mail/tasks, tools/communication, sbom
- compliance/evidence, uni-crawler, brandbook (already done)
- CollectionsTab, IngestionTab, RiskHeatmap

backend-lehrer (5 files):
- letters_api (641 → 2), certificates_api (636 → 2)
- alerts_agent/db/models (636 → 3)
- llm_gateway/communication_service (614 → 2)
- game/database already done in prior batch

klausur-service (2 files):
- hybrid_vocab_extractor (664 → 2)
- klausur-service/frontend: api.ts (620 → 3), EHUploadWizard (591 → 2)

voice-service (3 files):
- bqas/rag_judge (618 → 3), runner (529 → 2)
- enhanced_task_orchestrator (519 → 2)

studio-v2 (6 files):
- korrektur/[klausurId] (578 → 4), fairness (569 → 2)
- AlertsWizard (552 → 2), OnboardingWizard (513 → 2)
- korrektur/api.ts (506 → 3), geo-lernwelt (501 → 2)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-04-25 08:56:45 +02:00
parent b4613e26f3
commit 451365a312
115 changed files with 10694 additions and 13839 deletions

View File

@@ -0,0 +1,134 @@
import { StaffMember, Publication } from './types'
export function StaffDetailPanel({
selectedStaff,
publications,
}: {
selectedStaff: StaffMember | null
publications: Publication[]
}) {
if (!selectedStaff) {
return (
<div className="p-8 text-center text-slate-400">
Wahlen Sie eine Person aus der Liste
</div>
)
}
return (
<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>
)
}

View File

@@ -0,0 +1,97 @@
import { StaffMember } from './types'
import { getPositionBadgeColor } from './helpers'
export function StaffListItem({
member,
isSelected,
onClick,
}: {
member: StaffMember
isSelected: boolean
onClick: () => void
}) {
return (
<div
onClick={onClick}
className={`p-4 cursor-pointer hover:bg-gray-50 transition-colors ${
isSelected ? '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>
)
}

View File

@@ -0,0 +1,26 @@
export function 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'
}
}
export function 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'
}

View File

@@ -0,0 +1,52 @@
export 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
}
export interface Publication {
id: string
title: string
abstract?: string
year?: number
pub_type?: string
venue?: string
doi?: string
url?: string
citation_count: number
}
export 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>
}
export interface University {
id: string
name: string
short_name?: string
url: string
state?: string
uni_type?: string
}
export const EDU_SEARCH_API = process.env.NEXT_PUBLIC_EDU_SEARCH_URL || 'http://localhost:8086'

View File

@@ -0,0 +1,120 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { StaffMember, Publication, StaffStats, University, EDU_SEARCH_API } from './types'
export function useStaffSearch() {
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
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])
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)
}
return {
searchQuery, setSearchQuery,
staff, selectedStaff, publications, stats,
loading, error,
filterState, setFilterState,
filterUniType, setFilterUniType,
filterPositionType, setFilterPositionType,
filterProfessorsOnly, setFilterProfessorsOnly,
total, offset, limit,
searchStaff, handleSelectStaff,
}
}

View File

@@ -7,193 +7,23 @@
* 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'
import { useStaffSearch } from './_components/useStaffSearch'
import { StaffListItem } from './_components/StaffListItem'
import { StaffDetailPanel } from './_components/StaffDetailPanel'
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'
}
const {
searchQuery, setSearchQuery,
staff, selectedStaff, publications, stats,
loading, error,
filterState, setFilterState,
filterUniType, setFilterUniType,
filterPositionType, setFilterPositionType,
filterProfessorsOnly, setFilterProfessorsOnly,
total, offset, limit,
searchStaff, handleSelectStaff,
} = useStaffSearch()
return (
<AdminLayout
@@ -248,11 +78,7 @@ export default function StaffSearchPage() {
{/* 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"
>
<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>
@@ -271,12 +97,7 @@ export default function StaffSearchPage() {
<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"
>
<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>
@@ -284,12 +105,7 @@ export default function StaffSearchPage() {
<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"
>
<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>
@@ -297,14 +113,8 @@ export default function StaffSearchPage() {
<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"
/>
<input type="checkbox" checked={filterProfessorsOnly} onChange={(e) => setFilterProfessorsOnly(e.target.checked)} className="rounded border-gray-300" />
Nur Professoren
</label>
</div>
@@ -312,9 +122,7 @@ export default function StaffSearchPage() {
{/* Error */}
{error && (
<div className="bg-red-50 text-red-700 p-4 rounded-lg mb-4">
{error}
</div>
<div className="bg-red-50 text-red-700 p-4 rounded-lg mb-4">{error}</div>
)}
{/* Results */}
@@ -325,114 +133,21 @@ export default function StaffSearchPage() {
</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>
<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
<StaffListItem
key={member.id}
member={member}
isSelected={selectedStaff?.id === 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
@@ -440,11 +155,8 @@ export default function StaffSearchPage() {
: 'Geben Sie einen Suchbegriff ein oder wahlen Sie Filter'}
</div>
)}
{loading && (
<div className="p-8 text-center text-slate-500">
Suche lauft...
</div>
<div className="p-8 text-center text-slate-500">Suche lauft...</div>
)}
</div>
</div>
@@ -453,126 +165,7 @@ export default function StaffSearchPage() {
{/* 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>
)}
<StaffDetailPanel selectedStaff={selectedStaff} publications={publications} />
</div>
</div>
</div>