Files
Lageplan/src/components/dialogs/project-dialog.tsx

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