Files
breakpilot-lehrer/studio-v2/components/SchoolSearch.tsx
Benjamin Boenisch 5a31f52310 Initial commit: breakpilot-lehrer - Lehrer KI Platform
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>
2026-02-11 23:47:26 +01:00

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>
)
}