349 lines
12 KiB
TypeScript
349 lines
12 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useRef, useEffect, useCallback } from 'react'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Input } from '@/components/ui/input'
|
|
import { Label } from '@/components/ui/label'
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogFooter,
|
|
} from '@/components/ui/dialog'
|
|
import { useToast } from '@/components/ui/use-toast'
|
|
import { MapPin, Loader2, X } from 'lucide-react'
|
|
import type { Project } from '@/types'
|
|
|
|
interface NominatimResult {
|
|
place_id: number
|
|
display_name: string
|
|
lat: string
|
|
lon: string
|
|
type: string
|
|
address?: {
|
|
road?: string
|
|
house_number?: string
|
|
postcode?: string
|
|
city?: string
|
|
town?: string
|
|
village?: string
|
|
municipality?: string
|
|
state?: string
|
|
}
|
|
}
|
|
|
|
interface ProjectDialogProps {
|
|
open: boolean
|
|
onOpenChange: (open: boolean) => void
|
|
onProjectCreated: (project: Project) => void
|
|
}
|
|
|
|
export function ProjectDialog({
|
|
open,
|
|
onOpenChange,
|
|
onProjectCreated,
|
|
}: ProjectDialogProps) {
|
|
const [title, setTitle] = useState('')
|
|
const [location, setLocation] = useState('')
|
|
const [description, setDescription] = useState('')
|
|
const [einsatzleiter, setEinsatzleiter] = useState('')
|
|
const [journalfuehrer, setJournalfuehrer] = useState('')
|
|
const [isCreating, setIsCreating] = useState(false)
|
|
const { toast } = useToast()
|
|
|
|
// Address autocomplete state
|
|
const [suggestions, setSuggestions] = useState<NominatimResult[]>([])
|
|
const [isSearching, setIsSearching] = useState(false)
|
|
const [showSuggestions, setShowSuggestions] = useState(false)
|
|
const [selectedCoords, setSelectedCoords] = useState<{ lat: number; lng: number } | null>(null)
|
|
const debounceRef = useRef<NodeJS.Timeout | null>(null)
|
|
const suggestionsRef = useRef<HTMLDivElement>(null)
|
|
|
|
// Debounced Nominatim search
|
|
const searchAddress = useCallback((query: string) => {
|
|
if (debounceRef.current) clearTimeout(debounceRef.current)
|
|
if (query.length < 3) {
|
|
setSuggestions([])
|
|
setShowSuggestions(false)
|
|
return
|
|
}
|
|
debounceRef.current = setTimeout(async () => {
|
|
setIsSearching(true)
|
|
try {
|
|
const res = await fetch(
|
|
`https://nominatim.openstreetmap.org/search?` +
|
|
`q=${encodeURIComponent(query)}&format=json&addressdetails=1&limit=5&countrycodes=ch,de,at,li,fr,it`
|
|
)
|
|
if (res.ok) {
|
|
const data: NominatimResult[] = await res.json()
|
|
setSuggestions(data)
|
|
setShowSuggestions(data.length > 0)
|
|
}
|
|
} catch (e) {
|
|
console.warn('Nominatim search failed:', e)
|
|
} finally {
|
|
setIsSearching(false)
|
|
}
|
|
}, 350)
|
|
}, [])
|
|
|
|
const handleLocationChange = (value: string) => {
|
|
setLocation(value)
|
|
setSelectedCoords(null) // Clear coords when typing
|
|
searchAddress(value)
|
|
}
|
|
|
|
const handleSelectSuggestion = (result: NominatimResult) => {
|
|
// Build a clean display name
|
|
const addr = result.address
|
|
let displayName = result.display_name
|
|
if (addr) {
|
|
const parts: string[] = []
|
|
if (addr.road) {
|
|
parts.push(addr.road + (addr.house_number ? ' ' + addr.house_number : ''))
|
|
}
|
|
const city = addr.city || addr.town || addr.village || addr.municipality
|
|
if (addr.postcode && city) {
|
|
parts.push(`${addr.postcode} ${city}`)
|
|
} else if (city) {
|
|
parts.push(city)
|
|
}
|
|
if (parts.length > 0) displayName = parts.join(', ')
|
|
}
|
|
setLocation(displayName)
|
|
setSelectedCoords({ lat: parseFloat(result.lat), lng: parseFloat(result.lon) })
|
|
setSuggestions([])
|
|
setShowSuggestions(false)
|
|
}
|
|
|
|
// Close suggestions on click outside
|
|
useEffect(() => {
|
|
const handleClickOutside = (e: MouseEvent) => {
|
|
if (suggestionsRef.current && !suggestionsRef.current.contains(e.target as Node)) {
|
|
setShowSuggestions(false)
|
|
}
|
|
}
|
|
document.addEventListener('mousedown', handleClickOutside)
|
|
return () => document.removeEventListener('mousedown', handleClickOutside)
|
|
}, [])
|
|
|
|
const handleCreate = async () => {
|
|
if (!title.trim()) {
|
|
toast({
|
|
title: 'Fehler',
|
|
description: 'Bitte geben Sie einen Titel ein.',
|
|
variant: 'destructive',
|
|
})
|
|
return
|
|
}
|
|
|
|
setIsCreating(true)
|
|
try {
|
|
const body: any = {
|
|
title: title.trim(),
|
|
location: location.trim() || undefined,
|
|
description: description.trim() || undefined,
|
|
einsatzleiter: einsatzleiter.trim() || undefined,
|
|
journalfuehrer: journalfuehrer.trim() || undefined,
|
|
}
|
|
|
|
// If an address was selected with coordinates, set mapCenter
|
|
if (selectedCoords) {
|
|
body.mapCenter = { lng: selectedCoords.lng, lat: selectedCoords.lat }
|
|
body.mapZoom = 17
|
|
}
|
|
|
|
const res = await fetch('/api/projects', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
})
|
|
|
|
if (!res.ok) {
|
|
const data = await res.json()
|
|
throw new Error(data.error || 'Einsatz konnte nicht erstellt werden')
|
|
}
|
|
|
|
const data = await res.json()
|
|
onProjectCreated(data.project)
|
|
|
|
// Reset form
|
|
setTitle('')
|
|
setLocation('')
|
|
setDescription('')
|
|
setEinsatzleiter('')
|
|
setJournalfuehrer('')
|
|
setSelectedCoords(null)
|
|
setSuggestions([])
|
|
} catch (error) {
|
|
toast({
|
|
title: 'Fehler',
|
|
description: error instanceof Error ? error.message : 'Unbekannter Fehler',
|
|
variant: 'destructive',
|
|
})
|
|
} finally {
|
|
setIsCreating(false)
|
|
}
|
|
}
|
|
|
|
const handleClose = () => {
|
|
if (!isCreating) {
|
|
setTitle('')
|
|
setLocation('')
|
|
setDescription('')
|
|
setEinsatzleiter('')
|
|
setJournalfuehrer('')
|
|
setSelectedCoords(null)
|
|
setSuggestions([])
|
|
onOpenChange(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={handleClose}>
|
|
<DialogContent className="sm:max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle>Neuer Einsatz</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-4 py-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="project-title">Titel *</Label>
|
|
<Input
|
|
id="project-title"
|
|
value={title}
|
|
onChange={(e) => setTitle(e.target.value)}
|
|
placeholder="z.B. Wohnungsbrand Musterstrasse"
|
|
disabled={isCreating}
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="project-location">
|
|
Einsatzort
|
|
{selectedCoords && (
|
|
<span className="ml-2 text-xs text-green-600 font-normal inline-flex items-center gap-1">
|
|
<MapPin className="w-3 h-3" /> Koordinaten gesetzt
|
|
</span>
|
|
)}
|
|
</Label>
|
|
<div className="relative" ref={suggestionsRef}>
|
|
<div className="relative">
|
|
<Input
|
|
id="project-location"
|
|
value={location}
|
|
onChange={(e) => handleLocationChange(e.target.value)}
|
|
placeholder="Adresse eingeben — z.B. Bahnhofstrasse 1, Zürich"
|
|
disabled={isCreating}
|
|
autoComplete="off"
|
|
className={selectedCoords ? 'pr-8 border-green-300 focus:ring-green-500' : ''}
|
|
/>
|
|
{isSearching && (
|
|
<Loader2 className="absolute right-2.5 top-2.5 w-4 h-4 animate-spin text-muted-foreground" />
|
|
)}
|
|
{selectedCoords && !isSearching && (
|
|
<button
|
|
type="button"
|
|
onClick={() => { setSelectedCoords(null); setLocation('') }}
|
|
className="absolute right-2.5 top-2.5 text-muted-foreground hover:text-foreground"
|
|
>
|
|
<X className="w-4 h-4" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Autocomplete dropdown */}
|
|
{showSuggestions && suggestions.length > 0 && (
|
|
<div className="absolute z-50 w-full mt-1 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg max-h-60 overflow-auto">
|
|
{suggestions.map((s) => {
|
|
const addr = s.address
|
|
const city = addr?.city || addr?.town || addr?.village || addr?.municipality || ''
|
|
return (
|
|
<button
|
|
key={s.place_id}
|
|
type="button"
|
|
onClick={() => handleSelectSuggestion(s)}
|
|
className="w-full text-left px-3 py-2.5 hover:bg-gray-50 dark:hover:bg-gray-800 border-b border-gray-100 dark:border-gray-800 last:border-0 transition-colors"
|
|
>
|
|
<div className="flex items-start gap-2">
|
|
<MapPin className="w-4 h-4 text-red-500 mt-0.5 shrink-0" />
|
|
<div className="min-w-0">
|
|
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
|
|
{addr?.road
|
|
? `${addr.road}${addr.house_number ? ' ' + addr.house_number : ''}`
|
|
: s.display_name.split(',')[0]
|
|
}
|
|
</p>
|
|
<p className="text-xs text-gray-500 truncate">
|
|
{addr?.postcode ? `${addr.postcode} ` : ''}{city}
|
|
{addr?.state ? `, ${addr.state}` : ''}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
Adresse suchen — die Karte springt automatisch zum Einsatzort
|
|
</p>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="project-description">Beschreibung</Label>
|
|
<Input
|
|
id="project-description"
|
|
value={description}
|
|
onChange={(e) => setDescription(e.target.value)}
|
|
placeholder="Optionale Notizen zum Einsatz"
|
|
disabled={isCreating}
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="project-einsatzleiter">Einsatzleiter</Label>
|
|
<Input
|
|
id="project-einsatzleiter"
|
|
value={einsatzleiter}
|
|
onChange={(e) => setEinsatzleiter(e.target.value)}
|
|
placeholder="Name"
|
|
disabled={isCreating}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="project-journalfuehrer">Journalführer</Label>
|
|
<Input
|
|
id="project-journalfuehrer"
|
|
value={journalfuehrer}
|
|
onChange={(e) => setJournalfuehrer(e.target.value)}
|
|
placeholder="Name"
|
|
disabled={isCreating}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button
|
|
variant="outline"
|
|
onClick={handleClose}
|
|
disabled={isCreating}
|
|
>
|
|
Abbrechen
|
|
</Button>
|
|
<Button
|
|
onClick={handleCreate}
|
|
disabled={isCreating || !title.trim()}
|
|
>
|
|
{isCreating ? 'Erstellen...' : 'Erstellen'}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|