- Wind-Anzeige von der Karte entfernt (war doppelt und nicht als "Wind" erkennbar); MapCompass zeigt jetzt nur den Nordpfeil - Kopfzeile: Wind-Badge ist klickbar → Einsteller (Live vom Standort / Richtung wählen / entfernen), klar mit "Wind" beschriftet - Topbar erhält onWindChange/onFetchLiveWind (nur bei Bearbeitungsrecht) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
903 lines
39 KiB
TypeScript
903 lines
39 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useEffect } from 'react'
|
|
import { Button } from '@/components/ui/button'
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog'
|
|
import { ScrollArea } from '@/components/ui/scroll-area'
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuLabel,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
} from '@/components/ui/dropdown-menu'
|
|
import {
|
|
Flame,
|
|
GraduationCap,
|
|
Save,
|
|
FolderOpen,
|
|
Download,
|
|
Sun,
|
|
Moon,
|
|
Maximize,
|
|
Minimize,
|
|
ClipboardList,
|
|
Settings,
|
|
LogOut,
|
|
User,
|
|
MoreVertical,
|
|
Trash2,
|
|
AlertTriangle,
|
|
List,
|
|
Eraser,
|
|
ImagePlus,
|
|
Key,
|
|
Shield,
|
|
MapPin,
|
|
HelpCircle,
|
|
Lock,
|
|
Unlock,
|
|
CheckCircle2,
|
|
Plus,
|
|
ChevronDown,
|
|
Loader2,
|
|
Share2,
|
|
Wind,
|
|
Satellite,
|
|
} from 'lucide-react'
|
|
import { HoseSettingsDialog } from '@/components/dialogs/hose-settings-dialog'
|
|
import { ShareDialog } from '@/components/dialogs/share-dialog'
|
|
import { degToCompass } from '@/components/map/map-compass'
|
|
import type { Project, DrawFeature, ProjectMode } from '@/types'
|
|
import { formatDateTime } from '@/lib/utils'
|
|
import { Logo } from '@/components/ui/logo'
|
|
|
|
// 8-Richtungs-Auswahl (Grad, meteorologisch „Wind aus")
|
|
const WIND_OPTIONS: { label: string; deg: number }[] = [
|
|
{ label: 'N', deg: 0 }, { label: 'NO', deg: 45 }, { label: 'O', deg: 90 }, { label: 'SO', deg: 135 },
|
|
{ label: 'S', deg: 180 }, { label: 'SW', deg: 225 }, { label: 'W', deg: 270 }, { label: 'NW', deg: 315 },
|
|
]
|
|
|
|
interface TopbarProps {
|
|
project: Project | null
|
|
onNewProject: (mode?: ProjectMode) => void
|
|
onEndProject?: () => void
|
|
onSaveProject: () => void
|
|
onLoadProject: (project: Project, features: DrawFeature[]) => void
|
|
onDeleteProject?: (projectId: string) => void
|
|
onExport: (format: 'png' | 'pdf') => void
|
|
onClearAll?: () => void
|
|
onPlanUpload?: () => void
|
|
isSaving: boolean
|
|
isDarkMode: boolean
|
|
onToggleTheme: () => void
|
|
isFullscreen: boolean
|
|
onToggleFullscreen: () => void
|
|
auditLog: { time: string; action: string }[]
|
|
isAuditOpen: boolean
|
|
onToggleAudit: () => void
|
|
userName?: string
|
|
userRole?: string
|
|
onLogout?: () => void
|
|
onStartTour?: () => void
|
|
presentationLocked?: boolean
|
|
onTogglePresentationLock?: () => void
|
|
/** Windrichtung wird live aktualisiert (für die Live-Anzeige oben) */
|
|
windLive?: boolean
|
|
/** Windrichtung setzen/entfernen (Grad, meteorologisch „Wind aus"; null = entfernen) */
|
|
onWindChange?: (dir: number | null) => void
|
|
/** Live-Wind (Open-Meteo) vom Standort holen */
|
|
onFetchLiveWind?: () => Promise<void> | void
|
|
/** true, wenn der aktuelle Nutzer den Bearbeitungs-Lock hält */
|
|
isEditingByMe?: boolean
|
|
/** Karte speichern & für andere freigeben (Lock lösen) */
|
|
onStopEditing?: () => void
|
|
/** Lock-Aktion läuft gerade */
|
|
editingLoading?: boolean
|
|
}
|
|
|
|
export function Topbar({
|
|
project,
|
|
onNewProject,
|
|
onEndProject,
|
|
onSaveProject,
|
|
onLoadProject,
|
|
onDeleteProject,
|
|
onExport,
|
|
onClearAll,
|
|
onPlanUpload,
|
|
isSaving,
|
|
isDarkMode,
|
|
onToggleTheme,
|
|
isFullscreen,
|
|
onToggleFullscreen,
|
|
auditLog,
|
|
isAuditOpen,
|
|
onToggleAudit,
|
|
userName,
|
|
userRole,
|
|
onLogout,
|
|
onStartTour,
|
|
presentationLocked,
|
|
onTogglePresentationLock,
|
|
windLive,
|
|
onWindChange,
|
|
onFetchLiveWind,
|
|
isEditingByMe,
|
|
onStopEditing,
|
|
editingLoading,
|
|
}: TopbarProps) {
|
|
const [isLoadDialogOpen, setIsLoadDialogOpen] = useState(false)
|
|
const [isHoseSettingsOpen, setIsHoseSettingsOpen] = useState(false)
|
|
const [isShareOpen, setIsShareOpen] = useState(false)
|
|
const [showPasswordDialog, setShowPasswordDialog] = useState(false)
|
|
const [showDeleteAccountDialog, setShowDeleteAccountDialog] = useState(false)
|
|
const [deleteAccountPw, setDeleteAccountPw] = useState('')
|
|
const [deleteAccountLoading, setDeleteAccountLoading] = useState(false)
|
|
const [deleteAccountError, setDeleteAccountError] = useState('')
|
|
const [pwOld, setPwOld] = useState('')
|
|
const [pwNew, setPwNew] = useState('')
|
|
const [pwConfirm, setPwConfirm] = useState('')
|
|
const [pwLoading, setPwLoading] = useState(false)
|
|
const [pwStatus, setPwStatus] = useState<'success' | 'error' | null>(null)
|
|
const [pwError, setPwError] = useState('')
|
|
const [projects, setProjects] = useState<Project[]>([])
|
|
const [isLoadingProjects, setIsLoadingProjects] = useState(false)
|
|
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null)
|
|
const [isDeleting, setIsDeleting] = useState(false)
|
|
|
|
const [windLoading, setWindLoading] = useState(false)
|
|
|
|
// Live clock
|
|
const [now, setNow] = useState(new Date())
|
|
useEffect(() => {
|
|
const timer = setInterval(() => setNow(new Date()), 1000)
|
|
return () => clearInterval(timer)
|
|
}, [])
|
|
|
|
const handleOpenLoadDialog = async () => {
|
|
setIsLoadDialogOpen(true)
|
|
setIsLoadingProjects(true)
|
|
try {
|
|
const res = await fetch('/api/projects')
|
|
if (res.ok) {
|
|
const data = await res.json()
|
|
setProjects(data.projects)
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading projects:', error)
|
|
} finally {
|
|
setIsLoadingProjects(false)
|
|
}
|
|
}
|
|
|
|
const handleLoadProject = async (projectId: string) => {
|
|
try {
|
|
const res = await fetch(`/api/projects/${projectId}`)
|
|
if (res.ok) {
|
|
const data = await res.json()
|
|
onLoadProject(data.project, data.project.features || [])
|
|
setIsLoadDialogOpen(false)
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading project:', error)
|
|
}
|
|
}
|
|
|
|
const handleExport = (format: 'png' | 'pdf') => {
|
|
onExport(format)
|
|
}
|
|
|
|
return (
|
|
<header className="h-14 md:h-16 border-b border-border bg-card flex items-center gap-2 md:gap-3 px-2 md:px-4 shrink-0 print:hidden relative">
|
|
{/* Zone 1 — Identität */}
|
|
<div className="flex items-center gap-2 shrink-0">
|
|
<Logo size={32} />
|
|
<div className="hidden sm:flex flex-col leading-none">
|
|
<span className="font-bold text-sm tracking-wide">LAGEPLAN</span>
|
|
<span className="text-[10px] text-muted-foreground font-mono mt-0.5">v{process.env.APP_VERSION}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="h-8 w-px bg-border hidden md:block shrink-0" />
|
|
|
|
{/* Zone 2 — Aktiver Einsatz (Blickfang) */}
|
|
<div className="flex-1 min-w-0 flex items-center">
|
|
{project ? (
|
|
<div className="flex items-center gap-2 min-w-0 rounded-lg border border-border bg-muted/40 pl-2.5 pr-3 py-1.5">
|
|
<span
|
|
className={`w-2.5 h-2.5 rounded-full shrink-0 ${project.mode === 'UEBUNG' ? 'bg-blue-500' : 'bg-red-500 animate-pulse'}`}
|
|
title={project.mode === 'UEBUNG' ? 'Übung' : 'Einsatz'}
|
|
/>
|
|
{(project as any).einsatzNr && (
|
|
<span className="font-mono text-xs font-bold shrink-0">{(project as any).einsatzNr}</span>
|
|
)}
|
|
<span className="font-semibold text-sm truncate">{project.title}</span>
|
|
{project.location && (
|
|
<span className="hidden lg:inline text-sm text-muted-foreground truncate">· {project.location}</span>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<span className="text-sm text-muted-foreground hidden sm:inline">Kein Einsatz aktiv</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Zone 3 — Uhr + Aktionen */}
|
|
<div className="flex items-center gap-1 md:gap-1.5 shrink-0">
|
|
{/* Wind — klare, einzige Wind-Anzeige. Klick öffnet den Einsteller (Live vom Standort
|
|
oder Richtung wählen). Der Pfeil zeigt die Ausbreitungsrichtung, N oben. */}
|
|
{project && onWindChange && (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
{typeof project.windDirection === 'number' ? (
|
|
<button
|
|
className="hidden sm:flex items-center gap-1.5 rounded-md bg-blue-600 hover:bg-blue-700 text-white pl-1.5 pr-2 py-1 shrink-0"
|
|
title={`Wind aus ${degToCompass(project.windDirection)} (${project.windDirection}°) — treibt nach ${degToCompass(project.windDirection + 180)}. Klicken zum Ändern.`}
|
|
>
|
|
<Wind className="w-3.5 h-3.5 shrink-0 opacity-90" />
|
|
<svg viewBox="0 0 24 24" className="w-4 h-4 shrink-0" style={{ transform: `rotate(${project.windDirection + 180}deg)` }} aria-hidden>
|
|
<path d="M12 3 L18 16 L12 12.5 L6 16 Z" fill="currentColor" />
|
|
</svg>
|
|
<span className="text-xs font-bold leading-none whitespace-nowrap">
|
|
Wind aus {degToCompass(project.windDirection)}
|
|
</span>
|
|
{windLive && <span className="w-1.5 h-1.5 rounded-full bg-green-300 animate-pulse shrink-0" title="Live" />}
|
|
</button>
|
|
) : (
|
|
<Button variant="outline" size="sm" className="hidden sm:flex h-9 md:h-10 px-2 shrink-0" title="Windrichtung setzen">
|
|
<Wind className="w-4 h-4 lg:mr-1.5" />
|
|
<span className="hidden lg:inline">Wind</span>
|
|
</Button>
|
|
)}
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className="w-52 p-2">
|
|
<div className="flex items-center gap-1.5 px-1 pb-1.5 text-xs font-semibold text-muted-foreground">
|
|
<Wind className="w-3.5 h-3.5" /> Windrichtung (Wind aus)
|
|
</div>
|
|
{onFetchLiveWind && (
|
|
<button
|
|
onClick={async () => { setWindLoading(true); try { await onFetchLiveWind() } finally { setWindLoading(false) } }}
|
|
disabled={windLoading}
|
|
className="w-full mb-2 flex items-center justify-center gap-1.5 rounded-md bg-blue-600 hover:bg-blue-700 text-white text-xs font-semibold py-1.5 disabled:opacity-60"
|
|
>
|
|
{windLoading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Satellite className="w-3.5 h-3.5" />}
|
|
Live vom Standort
|
|
</button>
|
|
)}
|
|
<div className="text-[10px] text-muted-foreground mb-1 px-0.5">oder Richtung wählen:</div>
|
|
<div className="grid grid-cols-4 gap-1">
|
|
{WIND_OPTIONS.map((w) => (
|
|
<button
|
|
key={w.deg}
|
|
onClick={() => onWindChange(w.deg)}
|
|
className={`h-8 rounded-md text-xs font-semibold transition-colors ${
|
|
project.windDirection === w.deg ? 'bg-blue-600 text-white' : 'bg-muted hover:bg-accent text-foreground'
|
|
}`}
|
|
>
|
|
{w.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
{typeof project.windDirection === 'number' && (
|
|
<button
|
|
onClick={() => onWindChange(null)}
|
|
className="mt-2 w-full text-xs text-muted-foreground hover:text-destructive py-1"
|
|
>
|
|
Wind entfernen
|
|
</button>
|
|
)}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
)}
|
|
|
|
{/* Uhr — Ops-Rooms leben nach der Uhr */}
|
|
<span className="hidden sm:inline text-base md:text-lg font-bold tabular-nums mr-0.5 md:mr-1.5">
|
|
{now.toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' })}
|
|
</span>
|
|
|
|
{/* Speichern */}
|
|
<Button
|
|
data-tour="save"
|
|
variant="outline"
|
|
size="icon"
|
|
className="h-9 w-9 md:h-10 md:w-10 shrink-0"
|
|
onClick={onSaveProject}
|
|
disabled={!project || isSaving}
|
|
title="Speichern (Ctrl+S)"
|
|
>
|
|
{isSaving ? <Loader2 className="w-5 h-5 animate-spin" /> : <Save className="w-5 h-5" />}
|
|
</Button>
|
|
|
|
{/* Schnellzugriff: Vollbild + Präsentationsmodus (Desktop; mobil im ⋮-Menü) */}
|
|
<Button
|
|
variant="outline"
|
|
size="icon"
|
|
className="hidden md:flex h-10 w-10 shrink-0"
|
|
onClick={onToggleFullscreen}
|
|
title={isFullscreen ? 'Vollbild verlassen' : 'Vollbild'}
|
|
>
|
|
{isFullscreen ? <Minimize className="w-5 h-5" /> : <Maximize className="w-5 h-5" />}
|
|
</Button>
|
|
{onTogglePresentationLock && (
|
|
<Button
|
|
variant="outline"
|
|
size="icon"
|
|
className={`hidden md:flex h-10 w-10 shrink-0 ${presentationLocked ? 'border-amber-500 text-amber-600 dark:text-amber-400' : ''}`}
|
|
onClick={onTogglePresentationLock}
|
|
title={presentationLocked ? 'Präsentation: gesperrt (zum Entsperren tippen)' : 'Präsentationsmodus'}
|
|
>
|
|
{presentationLocked ? <Lock className="w-5 h-5" /> : <Unlock className="w-5 h-5" />}
|
|
</Button>
|
|
)}
|
|
|
|
{/* Freigeben — nur sichtbar, solange ich den Bearbeitungs-Lock halte.
|
|
Speichert und gibt die Karte für andere frei. Grün = ich bin am Zeichnen. */}
|
|
{isEditingByMe && onStopEditing && (
|
|
<Button
|
|
variant="outline"
|
|
className="h-9 md:h-10 px-2 md:px-2.5 shrink-0 border-green-500 text-green-700 hover:bg-green-50 dark:text-green-400 dark:hover:bg-green-950/40"
|
|
onClick={onStopEditing}
|
|
disabled={editingLoading}
|
|
title="Karte speichern & für andere freigeben"
|
|
>
|
|
{editingLoading ? <Loader2 className="w-5 h-5 animate-spin lg:mr-1" /> : <Unlock className="w-5 h-5 lg:mr-1" />}
|
|
<span className="hidden lg:inline">Freigeben</span>
|
|
</Button>
|
|
)}
|
|
|
|
{/* Neu — Split-Button für Einsatz/Übung (ein Akzent statt zwei farbiger Buttons) */}
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button data-tour="new-project" className="h-9 md:h-10 px-2 md:px-2.5 shrink-0" title="Neu erstellen">
|
|
<Plus className="w-5 h-5 lg:mr-1" />
|
|
<span className="hidden lg:inline">Neu</span>
|
|
<ChevronDown className="w-3.5 h-3.5 ml-0.5 opacity-70" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className="w-48">
|
|
<DropdownMenuItem onClick={() => onNewProject('EINSATZ')} className="py-2.5">
|
|
<Flame className="w-4 h-4 mr-2 text-red-600" />
|
|
Neuer Einsatz
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={() => onNewProject('UEBUNG')} className="py-2.5">
|
|
<GraduationCap className="w-4 h-4 mr-2 text-blue-600" />
|
|
Neue Übung
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
|
|
{/* Werkzeuge (Desktop) — sammelt alle Neben-Aktionen in einem Menü */}
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="outline" size="icon" className="hidden md:flex h-10 w-10 shrink-0" title="Werkzeuge">
|
|
<Settings className="w-5 h-5" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className="w-56">
|
|
<DropdownMenuItem onClick={handleOpenLoadDialog}>
|
|
<List className="w-4 h-4 mr-2" />
|
|
Einsätze verwalten
|
|
</DropdownMenuItem>
|
|
{project && (
|
|
<DropdownMenuItem onClick={() => setIsShareOpen(true)}>
|
|
<Share2 className="w-4 h-4 mr-2" />
|
|
Lage teilen (Nur-Ansicht)
|
|
</DropdownMenuItem>
|
|
)}
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem onClick={() => handleExport('png')}>
|
|
<Download className="w-4 h-4 mr-2" />
|
|
Als PNG exportieren
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={() => handleExport('pdf')}>
|
|
<Download className="w-4 h-4 mr-2" />
|
|
Als PDF exportieren
|
|
</DropdownMenuItem>
|
|
{onClearAll && (
|
|
<DropdownMenuItem onClick={onClearAll} className="text-destructive focus:text-destructive">
|
|
<Eraser className="w-4 h-4 mr-2" />
|
|
Zeichnung leeren
|
|
</DropdownMenuItem>
|
|
)}
|
|
<DropdownMenuSeparator />
|
|
{onTogglePresentationLock && (
|
|
<DropdownMenuItem onClick={onTogglePresentationLock}>
|
|
{presentationLocked ? <Lock className="w-4 h-4 mr-2" /> : <Unlock className="w-4 h-4 mr-2" />}
|
|
{presentationLocked ? 'Präsentation: gesperrt' : 'Präsentationsmodus'}
|
|
</DropdownMenuItem>
|
|
)}
|
|
<DropdownMenuItem onClick={onToggleFullscreen}>
|
|
{isFullscreen ? <Minimize className="w-4 h-4 mr-2" /> : <Maximize className="w-4 h-4 mr-2" />}
|
|
{isFullscreen ? 'Vollbild verlassen' : 'Vollbild'}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={onToggleTheme}>
|
|
{isDarkMode ? <Sun className="w-4 h-4 mr-2" /> : <Moon className="w-4 h-4 mr-2" />}
|
|
{isDarkMode ? 'Tagmodus' : 'Nachtmodus'}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={() => setIsHoseSettingsOpen(true)}>
|
|
<Settings className="w-4 h-4 mr-2" />
|
|
Schlaucheinstellungen
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
|
|
{/* Einsatz beenden (Desktop) */}
|
|
{project && onEndProject && (
|
|
<Button
|
|
variant="outline"
|
|
className="hidden md:flex h-10 px-2 lg:px-3 shrink-0 border-red-300 text-red-600 hover:bg-red-50 hover:text-red-700 dark:border-red-800 dark:text-red-400 dark:hover:bg-red-950/40"
|
|
onClick={onEndProject}
|
|
title={project.mode === 'UEBUNG' ? 'Übung speichern und schliessen' : 'Einsatz speichern und schliessen'}
|
|
>
|
|
<CheckCircle2 className="w-4 h-4 xl:mr-1" />
|
|
<span className="hidden xl:inline">Beenden</span>
|
|
</Button>
|
|
)}
|
|
|
|
{/* Benutzer (Desktop) */}
|
|
{userName && onLogout && (
|
|
<div className="hidden md:flex items-center ml-0.5 pl-1.5 border-l border-border">
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="ghost" size="icon" className="h-10 w-10 text-muted-foreground hover:text-foreground" title={userName}>
|
|
<User className="w-5 h-5" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className="w-52">
|
|
<div className="px-3 py-2 border-b">
|
|
<p className="text-sm font-medium">{userName}</p>
|
|
{userRole && <p className="text-xs text-muted-foreground">{userRole}</p>}
|
|
</div>
|
|
<DropdownMenuItem onClick={() => setShowPasswordDialog(true)}>
|
|
<Key className="w-4 h-4 mr-2" />
|
|
Kennwort ändern
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={() => window.location.href = '/settings'}>
|
|
<Lock className="w-4 h-4 mr-2" />
|
|
Sicherheit & 2FA
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={() => window.location.href = '/konto/datenschutz'}>
|
|
<Shield className="w-4 h-4 mr-2" />
|
|
Konto & Datenschutz
|
|
</DropdownMenuItem>
|
|
{(userRole === 'SERVER_ADMIN' || userRole === 'TENANT_ADMIN') && (
|
|
<DropdownMenuItem onClick={() => window.location.href = '/admin'}>
|
|
<Shield className="w-4 h-4 mr-2" />
|
|
Administration
|
|
</DropdownMenuItem>
|
|
)}
|
|
{onStartTour && (
|
|
<DropdownMenuItem onClick={onStartTour}>
|
|
<HelpCircle className="w-4 h-4 mr-2" />
|
|
Tour starten
|
|
</DropdownMenuItem>
|
|
)}
|
|
<DropdownMenuItem
|
|
onClick={() => { setShowDeleteAccountDialog(true); setDeleteAccountPw(''); setDeleteAccountError('') }}
|
|
className="text-destructive focus:text-destructive"
|
|
>
|
|
<Trash2 className="w-4 h-4 mr-2" />
|
|
Konto löschen
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={onLogout} className="text-destructive focus:text-destructive">
|
|
<LogOut className="w-4 h-4 mr-2" />
|
|
Abmelden
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
)}
|
|
|
|
{/* Mobile: EIN Menü mit allen Aktionen (ersetzt die früheren zwei getrennten Menüs) */}
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="outline" className="md:hidden h-9 px-2" title="Menü">
|
|
<MoreVertical className="w-5 h-5" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className="w-56 max-h-[80vh] overflow-y-auto">
|
|
{userName && (
|
|
<>
|
|
<DropdownMenuLabel className="text-xs text-muted-foreground font-normal">
|
|
{userName}{userRole ? ` · ${userRole}` : ''}
|
|
</DropdownMenuLabel>
|
|
<DropdownMenuSeparator />
|
|
</>
|
|
)}
|
|
|
|
{/* Einsatz / Übung (Erstellen liegt im „Neu"-Button) */}
|
|
{project && onEndProject && (
|
|
<DropdownMenuItem onClick={onEndProject} className="text-red-600 focus:text-red-700">
|
|
<CheckCircle2 className="w-4 h-4 mr-2" />
|
|
{project.mode === 'UEBUNG' ? 'Übung beenden' : 'Einsatz beenden'}
|
|
</DropdownMenuItem>
|
|
)}
|
|
<DropdownMenuItem onClick={handleOpenLoadDialog}>
|
|
<List className="w-4 h-4 mr-2" />
|
|
Einsätze verwalten
|
|
</DropdownMenuItem>
|
|
{project && (
|
|
<DropdownMenuItem onClick={() => setIsShareOpen(true)}>
|
|
<Share2 className="w-4 h-4 mr-2" />
|
|
Lage teilen (Nur-Ansicht)
|
|
</DropdownMenuItem>
|
|
)}
|
|
|
|
<DropdownMenuSeparator />
|
|
|
|
{/* Karte / Export */}
|
|
<DropdownMenuItem onClick={() => handleExport('png')}>
|
|
<Download className="w-4 h-4 mr-2" />
|
|
Als PNG exportieren
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={() => handleExport('pdf')}>
|
|
<Download className="w-4 h-4 mr-2" />
|
|
Als PDF exportieren
|
|
</DropdownMenuItem>
|
|
{onClearAll && (
|
|
<DropdownMenuItem onClick={onClearAll} className="text-destructive focus:text-destructive">
|
|
<Eraser className="w-4 h-4 mr-2" />
|
|
Zeichnung leeren
|
|
</DropdownMenuItem>
|
|
)}
|
|
{onTogglePresentationLock && (
|
|
<DropdownMenuItem onClick={onTogglePresentationLock}>
|
|
{presentationLocked ? <Lock className="w-4 h-4 mr-2" /> : <Unlock className="w-4 h-4 mr-2" />}
|
|
{presentationLocked ? 'Präsentation: gesperrt' : 'Präsentationsmodus'}
|
|
</DropdownMenuItem>
|
|
)}
|
|
|
|
<DropdownMenuSeparator />
|
|
|
|
{/* Ansicht */}
|
|
<DropdownMenuItem onClick={onToggleFullscreen}>
|
|
{isFullscreen ? <Minimize className="w-4 h-4 mr-2" /> : <Maximize className="w-4 h-4 mr-2" />}
|
|
{isFullscreen ? 'Vollbild verlassen' : 'Vollbild'}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={onToggleTheme}>
|
|
{isDarkMode ? <Sun className="w-4 h-4 mr-2" /> : <Moon className="w-4 h-4 mr-2" />}
|
|
{isDarkMode ? 'Tagmodus' : 'Nachtmodus'}
|
|
</DropdownMenuItem>
|
|
|
|
<DropdownMenuSeparator />
|
|
|
|
{/* System / Konto */}
|
|
{(userRole === 'SERVER_ADMIN' || userRole === 'TENANT_ADMIN') && (
|
|
<DropdownMenuItem onClick={() => window.location.href = '/admin'}>
|
|
<Shield className="w-4 h-4 mr-2" />
|
|
Administration
|
|
</DropdownMenuItem>
|
|
)}
|
|
<DropdownMenuItem onClick={() => setIsHoseSettingsOpen(true)}>
|
|
<Settings className="w-4 h-4 mr-2" />
|
|
Schlaucheinstellungen
|
|
</DropdownMenuItem>
|
|
{onStartTour && (
|
|
<DropdownMenuItem onClick={onStartTour}>
|
|
<HelpCircle className="w-4 h-4 mr-2" />
|
|
Tour starten
|
|
</DropdownMenuItem>
|
|
)}
|
|
<DropdownMenuItem onClick={() => setShowPasswordDialog(true)}>
|
|
<Key className="w-4 h-4 mr-2" />
|
|
Kennwort ändern
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={() => window.location.href = '/settings'}>
|
|
<Lock className="w-4 h-4 mr-2" />
|
|
Sicherheit & 2FA
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={() => window.location.href = '/konto/datenschutz'}>
|
|
<Shield className="w-4 h-4 mr-2" />
|
|
Konto & Datenschutz
|
|
</DropdownMenuItem>
|
|
{onLogout && (
|
|
<DropdownMenuItem onClick={onLogout} className="text-destructive focus:text-destructive">
|
|
<LogOut className="w-4 h-4 mr-2" />
|
|
Abmelden
|
|
</DropdownMenuItem>
|
|
)}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
|
|
{/* Audit Trail Panel */}
|
|
{isAuditOpen && (
|
|
<div className="absolute right-0 top-16 w-96 max-h-[60vh] z-50 bg-card border border-border rounded-bl-lg shadow-xl overflow-hidden flex flex-col">
|
|
<div className="flex items-center justify-between px-4 py-3 border-b border-border bg-muted/50">
|
|
<span className="font-semibold text-sm flex items-center gap-2">
|
|
<ClipboardList className="w-4 h-4" /> Audit Trail
|
|
</span>
|
|
<button onClick={onToggleAudit} className="text-muted-foreground hover:text-foreground text-lg leading-none">✕</button>
|
|
</div>
|
|
<ScrollArea className="flex-1 max-h-[calc(60vh-48px)]">
|
|
<div className="p-2">
|
|
{auditLog.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground text-center py-8">Noch keine Aktionen</p>
|
|
) : (
|
|
auditLog.map((entry, i) => (
|
|
<div key={i} className="flex gap-3 px-3 py-2 text-sm border-b border-border/50 last:border-0">
|
|
<span className="text-muted-foreground font-mono text-xs whitespace-nowrap pt-0.5">{entry.time}</span>
|
|
<span>{entry.action}</span>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
</ScrollArea>
|
|
</div>
|
|
)}
|
|
|
|
{/* Project Management Dialog */}
|
|
<Dialog open={isLoadDialogOpen} onOpenChange={setIsLoadDialogOpen}>
|
|
<DialogContent className="max-w-2xl">
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2">
|
|
<List className="w-5 h-5" />
|
|
Einsätze verwalten
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
<ScrollArea className="h-[450px] pr-4">
|
|
{isLoadingProjects ? (
|
|
<div className="flex items-center justify-center h-32">
|
|
<span className="text-muted-foreground">Laden...</span>
|
|
</div>
|
|
) : projects.length === 0 ? (
|
|
<div className="flex items-center justify-center h-32">
|
|
<span className="text-muted-foreground">Keine Einsätze vorhanden</span>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{projects.map((p) => (
|
|
<div
|
|
key={p.id}
|
|
className={`p-4 border rounded-lg transition-colors ${
|
|
project?.id === p.id ? 'border-primary bg-primary/5' : 'hover:bg-accent'
|
|
}`}
|
|
>
|
|
<div className="flex items-start justify-between gap-3">
|
|
<button
|
|
onClick={() => handleLoadProject(p.id)}
|
|
className="flex-1 text-left"
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<h4 className="font-medium">{p.title}</h4>
|
|
{(p as any).mode === 'UEBUNG' ? (
|
|
<span className="text-xs bg-blue-100 text-blue-700 dark:bg-blue-950/50 dark:text-blue-300 px-1.5 py-0.5 rounded">Übung</span>
|
|
) : (
|
|
<span className="text-xs bg-red-100 text-red-700 dark:bg-red-950/50 dark:text-red-300 px-1.5 py-0.5 rounded">Einsatz</span>
|
|
)}
|
|
{project?.id === p.id && (
|
|
<span className="text-xs bg-primary text-primary-foreground px-1.5 py-0.5 rounded">Aktiv</span>
|
|
)}
|
|
</div>
|
|
{p.location && (
|
|
<p className="text-sm text-muted-foreground mt-1">
|
|
<MapPin className="w-3 h-3 inline mr-1" />{p.location}
|
|
</p>
|
|
)}
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|
{(p as any).owner?.name && (
|
|
<><span className="font-medium text-foreground/70">{(p as any).owner.name}</span> · </>
|
|
)}
|
|
Erstellt: {formatDateTime(p.createdAt)} | Geändert: {formatDateTime(p.updatedAt)}
|
|
</p>
|
|
</button>
|
|
<div className="flex items-center gap-1 shrink-0">
|
|
<Button
|
|
variant="outline"
|
|
size="icon"
|
|
className="h-8 w-8"
|
|
onClick={() => handleLoadProject(p.id)}
|
|
title="Einsatz öffnen"
|
|
>
|
|
<FolderOpen className="w-4 h-4" />
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="icon"
|
|
className="h-8 w-8 text-destructive hover:bg-destructive hover:text-destructive-foreground"
|
|
onClick={() => setDeleteConfirmId(p.id)}
|
|
title="Einsatz löschen"
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</ScrollArea>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Delete Confirmation Dialog */}
|
|
<Dialog open={!!deleteConfirmId} onOpenChange={() => setDeleteConfirmId(null)}>
|
|
<DialogContent className="max-w-sm">
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2 text-destructive">
|
|
<AlertTriangle className="w-5 h-5" />
|
|
Einsatz löschen?
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
<p className="text-sm text-muted-foreground">
|
|
Dieser Einsatz wird unwiderruflich gelöscht, inklusive aller Zeichnungen, Journal-Einträge und Daten.
|
|
</p>
|
|
<div className="flex justify-end gap-2 mt-2">
|
|
<Button variant="outline" onClick={() => setDeleteConfirmId(null)} disabled={isDeleting}>
|
|
Abbrechen
|
|
</Button>
|
|
<Button
|
|
variant="destructive"
|
|
disabled={isDeleting}
|
|
onClick={async () => {
|
|
if (!deleteConfirmId) return
|
|
setIsDeleting(true)
|
|
try {
|
|
const res = await fetch(`/api/projects/${deleteConfirmId}`, { method: 'DELETE' })
|
|
if (res.ok) {
|
|
setProjects(prev => prev.filter(p => p.id !== deleteConfirmId))
|
|
if (onDeleteProject) onDeleteProject(deleteConfirmId)
|
|
setDeleteConfirmId(null)
|
|
}
|
|
} catch (e) {
|
|
console.error('Delete failed:', e)
|
|
} finally {
|
|
setIsDeleting(false)
|
|
}
|
|
}}
|
|
>
|
|
{isDeleting ? 'Löschen...' : 'Endgültig löschen'}
|
|
</Button>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<HoseSettingsDialog open={isHoseSettingsOpen} onOpenChange={setIsHoseSettingsOpen} />
|
|
|
|
<ShareDialog open={isShareOpen} onOpenChange={setIsShareOpen} projectId={project?.id ?? null} projectTitle={project?.title} />
|
|
|
|
{/* Password Change Dialog */}
|
|
<Dialog open={showPasswordDialog} onOpenChange={(open) => {
|
|
setShowPasswordDialog(open)
|
|
if (!open) { setPwOld(''); setPwNew(''); setPwConfirm(''); setPwStatus(null); setPwError('') }
|
|
}}>
|
|
<DialogContent className="max-w-sm">
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2">
|
|
<Key className="w-5 h-5" />
|
|
Kennwort ändern
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="space-y-3">
|
|
<div>
|
|
<label className="text-xs font-medium text-muted-foreground">Aktuelles Kennwort</label>
|
|
<input type="password" value={pwOld} onChange={e => setPwOld(e.target.value)} className="w-full border rounded-md px-3 py-2 text-sm mt-1" autoComplete="current-password" />
|
|
</div>
|
|
<div>
|
|
<label className="text-xs font-medium text-muted-foreground">Neues Kennwort</label>
|
|
<input type="password" value={pwNew} onChange={e => setPwNew(e.target.value)} className="w-full border rounded-md px-3 py-2 text-sm mt-1" autoComplete="new-password" />
|
|
</div>
|
|
<div>
|
|
<label className="text-xs font-medium text-muted-foreground">Kennwort bestätigen</label>
|
|
<input type="password" value={pwConfirm} onChange={e => setPwConfirm(e.target.value)} className="w-full border rounded-md px-3 py-2 text-sm mt-1" autoComplete="new-password" />
|
|
</div>
|
|
{pwStatus === 'error' && <p className="text-sm text-destructive">{pwError}</p>}
|
|
{pwStatus === 'success' && <p className="text-sm text-green-600">Kennwort erfolgreich geändert!</p>}
|
|
</div>
|
|
<div className="flex justify-end gap-2 mt-2">
|
|
<Button variant="outline" size="sm" onClick={() => setShowPasswordDialog(false)}>Abbrechen</Button>
|
|
<Button
|
|
size="sm"
|
|
disabled={pwLoading || !pwOld || !pwNew || pwNew !== pwConfirm || pwNew.length < 6}
|
|
onClick={async () => {
|
|
setPwLoading(true)
|
|
setPwStatus(null)
|
|
setPwError('')
|
|
try {
|
|
const res = await fetch('/api/auth/change-password', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ currentPassword: pwOld, newPassword: pwNew }),
|
|
})
|
|
const data = await res.json()
|
|
if (res.ok) {
|
|
setPwStatus('success')
|
|
setPwOld(''); setPwNew(''); setPwConfirm('')
|
|
} else {
|
|
setPwStatus('error')
|
|
setPwError(data.error || 'Fehler beim Ändern')
|
|
}
|
|
} catch { setPwStatus('error'); setPwError('Verbindungsfehler') }
|
|
finally { setPwLoading(false) }
|
|
}}
|
|
>
|
|
{pwLoading ? 'Speichern...' : 'Kennwort ändern'}
|
|
</Button>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Delete Account Dialog */}
|
|
<Dialog open={showDeleteAccountDialog} onOpenChange={setShowDeleteAccountDialog}>
|
|
<DialogContent className="max-w-sm">
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2 text-destructive">
|
|
<AlertTriangle className="w-5 h-5" />
|
|
Konto löschen
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="space-y-4">
|
|
<p className="text-sm text-muted-foreground">
|
|
Ihr Konto wird unwiderruflich gelöscht. Ihre Projekte und Daten bleiben der Organisation erhalten,
|
|
aber Ihr persönlicher Zugang wird entfernt.
|
|
</p>
|
|
{userRole === 'TENANT_ADMIN' && (
|
|
<div className="bg-amber-50 dark:bg-amber-950/30 rounded-lg p-3 text-xs text-amber-800 dark:text-amber-300 border border-amber-200 dark:border-amber-800">
|
|
<strong>Hinweis:</strong> Als einziger Administrator müssen Sie zuerst die Organisation unter Einstellungen löschen oder die Admin-Rolle übertragen.
|
|
</div>
|
|
)}
|
|
<div className="space-y-1.5">
|
|
<label className="text-sm font-medium">Passwort zur Bestätigung</label>
|
|
<input
|
|
type="password"
|
|
value={deleteAccountPw}
|
|
onChange={(e) => { setDeleteAccountPw(e.target.value); setDeleteAccountError('') }}
|
|
placeholder="Ihr Passwort"
|
|
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
|
autoComplete="current-password"
|
|
/>
|
|
</div>
|
|
{deleteAccountError && (
|
|
<p className="text-sm text-destructive">{deleteAccountError}</p>
|
|
)}
|
|
<div className="flex gap-2 justify-end">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setShowDeleteAccountDialog(false)}
|
|
disabled={deleteAccountLoading}
|
|
>
|
|
Abbrechen
|
|
</Button>
|
|
<Button
|
|
variant="destructive"
|
|
size="sm"
|
|
disabled={deleteAccountLoading || !deleteAccountPw}
|
|
onClick={async () => {
|
|
setDeleteAccountLoading(true)
|
|
setDeleteAccountError('')
|
|
try {
|
|
const res = await fetch('/api/auth/delete-account', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ password: deleteAccountPw }),
|
|
})
|
|
const data = await res.json()
|
|
if (res.ok) {
|
|
window.location.href = '/'
|
|
} else {
|
|
setDeleteAccountError(data.error || 'Löschung fehlgeschlagen')
|
|
}
|
|
} catch {
|
|
setDeleteAccountError('Verbindungsfehler')
|
|
} finally {
|
|
setDeleteAccountLoading(false)
|
|
}
|
|
}}
|
|
>
|
|
{deleteAccountLoading ? 'Wird gelöscht...' : 'Konto endgültig löschen'}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</header>
|
|
)
|
|
}
|