A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.
This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).
Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
340 lines
13 KiB
TypeScript
340 lines
13 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useEffect, useRef, useCallback } from 'react'
|
|
import { useTheme } from '@/lib/ThemeContext'
|
|
|
|
interface SchoolSearchProps {
|
|
city: string
|
|
bundesland: string
|
|
bundeslandName: string
|
|
selectedSchool: string
|
|
onSelectSchool: (schoolName: string, schoolId?: string) => void
|
|
className?: string
|
|
}
|
|
|
|
interface SchoolSuggestion {
|
|
id: string
|
|
name: string
|
|
type: string
|
|
address?: string
|
|
city?: string
|
|
source: 'api' | 'mock'
|
|
}
|
|
|
|
// Edu-Search API URL - uses HTTPS proxy on port 8089 (edu-search runs on 8088)
|
|
const getApiUrl = () => {
|
|
if (typeof window === 'undefined') return 'http://localhost:8088'
|
|
const { hostname, protocol } = window.location
|
|
// localhost: direct HTTP to 8088, macmini: HTTPS via nginx proxy on 8089
|
|
return hostname === 'localhost' ? 'http://localhost:8088' : `${protocol}//${hostname}:8089`
|
|
}
|
|
|
|
// Attribution data for Open Data sources by Bundesland (CTRL-SRC-001)
|
|
const BUNDESLAND_ATTRIBUTION: Record<string, { source: string; license: string; licenseUrl: string }> = {
|
|
BW: { source: 'Open Data Baden-Wuerttemberg', license: 'DL-DE-BY-2.0', licenseUrl: 'https://www.govdata.de/dl-de/by-2-0' },
|
|
BY: { source: 'Open Data Bayern', license: 'DL-DE-BY-2.0', licenseUrl: 'https://www.govdata.de/dl-de/by-2-0' },
|
|
BE: { source: 'Datenportal Berlin', license: 'CC-BY', licenseUrl: 'https://creativecommons.org/licenses/by/4.0/' },
|
|
BB: { source: 'Daten Brandenburg', license: 'DL-DE-BY-2.0', licenseUrl: 'https://www.govdata.de/dl-de/by-2-0' },
|
|
HB: { source: 'Open Data Bremen', license: 'CC-BY', licenseUrl: 'https://creativecommons.org/licenses/by/4.0/' },
|
|
HH: { source: 'Transparenzportal Hamburg', license: 'DL-DE-BY-2.0', licenseUrl: 'https://www.govdata.de/dl-de/by-2-0' },
|
|
HE: { source: 'Open Data Hessen', license: 'DL-DE-BY-2.0', licenseUrl: 'https://www.govdata.de/dl-de/by-2-0' },
|
|
MV: { source: 'Open Data MV', license: 'DL-DE-BY-2.0', licenseUrl: 'https://www.govdata.de/dl-de/by-2-0' },
|
|
NI: { source: 'Open Data Niedersachsen', license: 'DL-DE-BY-2.0', licenseUrl: 'https://www.govdata.de/dl-de/by-2-0' },
|
|
NW: { source: 'Open.NRW', license: 'DL-DE-BY-2.0', licenseUrl: 'https://www.govdata.de/dl-de/by-2-0' },
|
|
RP: { source: 'Open Data Rheinland-Pfalz', license: 'DL-DE-BY-2.0', licenseUrl: 'https://www.govdata.de/dl-de/by-2-0' },
|
|
SL: { source: 'Open Data Saarland', license: 'DL-DE-BY-2.0', licenseUrl: 'https://www.govdata.de/dl-de/by-2-0' },
|
|
SN: { source: 'Datenportal Sachsen', license: 'DL-DE-BY-2.0', licenseUrl: 'https://www.govdata.de/dl-de/by-2-0' },
|
|
ST: { source: 'Open Data Sachsen-Anhalt', license: 'DL-DE-BY-2.0', licenseUrl: 'https://www.govdata.de/dl-de/by-2-0' },
|
|
SH: { source: 'Open Data Schleswig-Holstein', license: 'DL-DE-BY-2.0', licenseUrl: 'https://www.govdata.de/dl-de/by-2-0' },
|
|
TH: { source: 'Open Data Thueringen', license: 'DL-DE-BY-2.0', licenseUrl: 'https://www.govdata.de/dl-de/by-2-0' },
|
|
}
|
|
|
|
// Category to display type mapping
|
|
const getCategoryDisplayType = (category?: string): string => {
|
|
switch (category) {
|
|
case 'primary': return 'Grundschule'
|
|
case 'secondary': return 'Sekundarschule'
|
|
case 'vocational': return 'Berufsschule'
|
|
case 'special': return 'Foerderschule'
|
|
default: return 'Schule'
|
|
}
|
|
}
|
|
|
|
export function SchoolSearch({
|
|
city,
|
|
bundesland,
|
|
bundeslandName,
|
|
selectedSchool,
|
|
onSelectSchool,
|
|
className = ''
|
|
}: SchoolSearchProps) {
|
|
const { isDark } = useTheme()
|
|
const [searchQuery, setSearchQuery] = useState(selectedSchool || '')
|
|
const [suggestions, setSuggestions] = useState<SchoolSuggestion[]>([])
|
|
const [isSearching, setIsSearching] = useState(false)
|
|
const [showSuggestions, setShowSuggestions] = useState(false)
|
|
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
|
const containerRef = useRef<HTMLDivElement>(null)
|
|
|
|
// Suche nach Schulen (echte API)
|
|
const searchSchools = useCallback(async (query: string) => {
|
|
if (query.length < 2) {
|
|
setSuggestions([])
|
|
return
|
|
}
|
|
|
|
setIsSearching(true)
|
|
|
|
try {
|
|
// Real API call to edu-search-service
|
|
const params = new URLSearchParams({
|
|
q: query,
|
|
limit: '10',
|
|
})
|
|
if (city) params.append('city', city)
|
|
if (bundesland) params.append('state', bundesland)
|
|
|
|
const response = await fetch(`${getApiUrl()}/api/v1/schools/search?${params}`)
|
|
|
|
if (response.ok) {
|
|
const data = await response.json()
|
|
const apiSchools: SchoolSuggestion[] = (data.schools || []).map((school: {
|
|
id: string
|
|
name: string
|
|
school_type_name?: string
|
|
school_category?: string
|
|
street?: string
|
|
city?: string
|
|
postal_code?: string
|
|
}) => ({
|
|
id: school.id,
|
|
name: school.name,
|
|
type: school.school_type_name || getCategoryDisplayType(school.school_category),
|
|
address: school.street ? `${school.street}, ${school.postal_code || ''} ${school.city || ''}`.trim() : undefined,
|
|
city: school.city,
|
|
source: 'api' as const,
|
|
}))
|
|
setSuggestions(apiSchools)
|
|
} else {
|
|
console.error('School search API error:', response.status)
|
|
setSuggestions([])
|
|
}
|
|
} catch (error) {
|
|
console.error('School search error:', error)
|
|
setSuggestions([])
|
|
} finally {
|
|
setIsSearching(false)
|
|
}
|
|
}, [city, bundesland])
|
|
|
|
// Debounced Search
|
|
useEffect(() => {
|
|
if (searchTimeoutRef.current) {
|
|
clearTimeout(searchTimeoutRef.current)
|
|
}
|
|
|
|
searchTimeoutRef.current = setTimeout(() => {
|
|
searchSchools(searchQuery)
|
|
}, 300)
|
|
|
|
return () => {
|
|
if (searchTimeoutRef.current) {
|
|
clearTimeout(searchTimeoutRef.current)
|
|
}
|
|
}
|
|
}, [searchQuery, searchSchools])
|
|
|
|
// Klick ausserhalb schließt Dropdown
|
|
useEffect(() => {
|
|
const handleClickOutside = (event: MouseEvent) => {
|
|
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
|
setShowSuggestions(false)
|
|
}
|
|
}
|
|
document.addEventListener('mousedown', handleClickOutside)
|
|
return () => document.removeEventListener('mousedown', handleClickOutside)
|
|
}, [])
|
|
|
|
// Schule auswaehlen
|
|
const handleSelectSchool = (school: SchoolSuggestion) => {
|
|
setSearchQuery(school.name)
|
|
onSelectSchool(school.name, school.id)
|
|
setSuggestions([])
|
|
setShowSuggestions(false)
|
|
}
|
|
|
|
// Manuelle Eingabe bestaetigen
|
|
const handleManualInput = () => {
|
|
if (searchQuery.trim()) {
|
|
onSelectSchool(searchQuery.trim())
|
|
setShowSuggestions(false)
|
|
}
|
|
}
|
|
|
|
// Schultyp-Icon
|
|
const getSchoolTypeIcon = (type: string) => {
|
|
switch (type.toLowerCase()) {
|
|
case 'gymnasium': return '🎓'
|
|
case 'gesamtschule':
|
|
case 'stadtteilschule': return '🏫'
|
|
case 'realschule': return '📚'
|
|
case 'hauptschule':
|
|
case 'mittelschule': return '📖'
|
|
case 'grundschule': return '🏠'
|
|
case 'berufsschule': return '🔧'
|
|
case 'foerderschule': return '🤝'
|
|
case 'privatschule': return '🏰'
|
|
case 'waldorfschule': return '🌿'
|
|
default: return '🏫'
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className={`space-y-4 ${className}`} ref={containerRef}>
|
|
{/* Suchfeld */}
|
|
<div className="relative">
|
|
<div className="relative">
|
|
<input
|
|
type="text"
|
|
value={searchQuery}
|
|
onChange={(e) => {
|
|
setSearchQuery(e.target.value)
|
|
setShowSuggestions(true)
|
|
}}
|
|
onFocus={() => setShowSuggestions(true)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') {
|
|
handleManualInput()
|
|
}
|
|
}}
|
|
placeholder={`Schule in ${city} suchen...`}
|
|
className={`w-full px-6 py-4 pl-14 text-lg rounded-2xl border transition-all ${
|
|
isDark
|
|
? 'bg-white/10 border-white/20 text-white placeholder-white/40 focus:border-blue-400'
|
|
: 'bg-white border-slate-300 text-slate-900 placeholder-slate-400 focus:border-blue-500'
|
|
} focus:outline-none focus:ring-2 focus:ring-blue-500/30`}
|
|
autoFocus
|
|
/>
|
|
<svg
|
|
className={`absolute left-5 top-1/2 -translate-y-1/2 w-5 h-5 ${
|
|
isDark ? 'text-white/40' : 'text-slate-400'
|
|
}`}
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
|
</svg>
|
|
{isSearching && (
|
|
<div className={`absolute right-4 top-1/2 -translate-y-1/2 w-5 h-5 border-2 border-t-transparent rounded-full animate-spin ${
|
|
isDark ? 'border-white/40' : 'border-slate-400'
|
|
}`} />
|
|
)}
|
|
</div>
|
|
|
|
{/* Vorschlaege Dropdown */}
|
|
{showSuggestions && suggestions.length > 0 && (
|
|
<div className={`absolute top-full left-0 right-0 mt-2 rounded-xl border shadow-xl z-50 max-h-80 overflow-y-auto ${
|
|
isDark
|
|
? 'bg-slate-800/95 border-white/20 backdrop-blur-xl'
|
|
: 'bg-white/95 border-slate-200 backdrop-blur-xl'
|
|
}`}>
|
|
{suggestions.map((school, index) => (
|
|
<button
|
|
key={school.id}
|
|
onClick={() => handleSelectSchool(school)}
|
|
className={`w-full px-4 py-3 text-left transition-colors flex items-center gap-3 ${
|
|
isDark
|
|
? 'hover:bg-white/10 text-white/90'
|
|
: 'hover:bg-slate-100 text-slate-800'
|
|
} ${index > 0 ? (isDark ? 'border-t border-white/10' : 'border-t border-slate-100') : ''}`}
|
|
>
|
|
<span className="text-xl flex-shrink-0">{getSchoolTypeIcon(school.type)}</span>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="font-medium truncate">{school.name}</p>
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<span className={`text-xs px-2 py-0.5 rounded-full ${
|
|
isDark ? 'bg-white/10 text-white/60' : 'bg-slate-100 text-slate-500'
|
|
}`}>
|
|
{school.type}
|
|
</span>
|
|
{school.address && (
|
|
<span className={`text-xs truncate ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
|
|
{school.address}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{school.source === 'api' && (
|
|
<span className={`text-xs px-2 py-0.5 rounded ${
|
|
isDark ? 'bg-green-500/20 text-green-300' : 'bg-green-100 text-green-700'
|
|
}`}>
|
|
verifiziert
|
|
</span>
|
|
)}
|
|
</button>
|
|
))}
|
|
{/* Attribution Footer (CTRL-SRC-001) */}
|
|
{(() => {
|
|
const attr = BUNDESLAND_ATTRIBUTION[bundesland]
|
|
return attr ? (
|
|
<div className={`px-4 py-2 text-xs border-t ${
|
|
isDark ? 'bg-slate-900/50 border-white/10 text-white/40' : 'bg-slate-50 border-slate-200 text-slate-500'
|
|
}`}>
|
|
<div className="flex items-center gap-1 flex-wrap">
|
|
<span>Quelle:</span>
|
|
<span className="font-medium">{attr.source}</span>
|
|
<span>•</span>
|
|
<a
|
|
href={attr.licenseUrl}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className={`underline hover:no-underline ${isDark ? 'text-blue-400' : 'text-blue-600'}`}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{attr.license}
|
|
</a>
|
|
</div>
|
|
</div>
|
|
) : null
|
|
})()}
|
|
</div>
|
|
)}
|
|
|
|
{/* Keine Ergebnisse Info */}
|
|
{showSuggestions && searchQuery.length >= 2 && suggestions.length === 0 && !isSearching && (
|
|
<div className={`absolute top-full left-0 right-0 mt-2 rounded-xl border p-4 ${
|
|
isDark
|
|
? 'bg-slate-800/95 border-white/20 backdrop-blur-xl'
|
|
: 'bg-white/95 border-slate-200 backdrop-blur-xl'
|
|
}`}>
|
|
<p className={`text-sm text-center ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
|
|
Keine Schulen gefunden. Tippen Sie den Namen ein und druecken Sie Enter.
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Standort Info */}
|
|
<div className={`p-4 rounded-xl ${isDark ? 'bg-white/5' : 'bg-slate-50'}`}>
|
|
<div className="flex items-start gap-3">
|
|
<span className="text-2xl">📍</span>
|
|
<div className="text-left flex-1">
|
|
<p className={`text-sm ${isDark ? 'text-white/80' : 'text-slate-700'}`}>
|
|
<strong>{city}</strong>, {bundeslandName}
|
|
</p>
|
|
<p className={`text-xs ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
|
|
Ihr Standort
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Hinweis */}
|
|
<p className={`text-xs text-center ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
|
|
Waehlen Sie aus den Vorschlaegen oder geben Sie den Namen manuell ein
|
|
</p>
|
|
</div>
|
|
)
|
|
}
|