Initial commit: Lageplan v1.0 - Next.js 15.5, React 19

This commit is contained in:
Pepe Ziberi
2026-02-21 11:57:44 +01:00
commit adf3dc8c1d
167 changed files with 34265 additions and 0 deletions

View File

@@ -0,0 +1,542 @@
'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,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
Plus,
Save,
FolderOpen,
Download,
Clock,
Sun,
Moon,
Maximize,
Minimize,
ClipboardList,
Settings,
LogOut,
User,
MoreVertical,
Trash2,
AlertTriangle,
List,
Eraser,
ImagePlus,
Key,
Shield,
Building2,
} from 'lucide-react'
import { HoseSettingsDialog } from '@/components/dialogs/hose-settings-dialog'
import type { Project, DrawFeature } from '@/app/app/page'
import { formatDateTime } from '@/lib/utils'
import { Logo } from '@/components/ui/logo'
interface TopbarProps {
project: Project | null
onNewProject: () => 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
}
export function Topbar({
project,
onNewProject,
onSaveProject,
onLoadProject,
onDeleteProject,
onExport,
onClearAll,
onPlanUpload,
isSaving,
isDarkMode,
onToggleTheme,
isFullscreen,
onToggleFullscreen,
auditLog,
isAuditOpen,
onToggleAudit,
userName,
userRole,
onLogout,
}: TopbarProps) {
const [isLoadDialogOpen, setIsLoadDialogOpen] = useState(false)
const [isHoseSettingsOpen, setIsHoseSettingsOpen] = useState(false)
const [showPasswordDialog, setShowPasswordDialog] = useState(false)
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)
// 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 justify-between px-2 md:px-4 shrink-0 overflow-x-auto print:hidden">
<div className="flex items-center gap-1.5 md:gap-4 shrink-0">
<div className="flex items-center gap-1.5 md:gap-2">
<Logo size={32} />
<span className="font-semibold text-lg hidden md:inline">Lageplan</span>
</div>
<div className="h-6 w-px bg-border hidden md:block" />
<div className="flex items-center gap-1 md:gap-2">
<Button
variant="outline"
className="h-9 md:h-10 px-2 md:px-4 text-sm"
onClick={onSaveProject}
disabled={!project || isSaving}
title="Speichern"
>
<Save className="w-5 h-5 md:mr-1.5" />
<span className="hidden lg:inline">{isSaving ? 'Speichern...' : 'Speichern'}</span>
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="h-9 md:h-10 px-2 md:px-3 text-sm" title="Menü">
<MoreVertical className="w-5 h-5 md:mr-1" />
<span className="hidden lg:inline">Menü</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-52">
<DropdownMenuItem onClick={onNewProject} className="py-2.5 px-3">
<Plus className="w-4 h-4 mr-2" />
Neuer Einsatz
</DropdownMenuItem>
<DropdownMenuItem onClick={handleOpenLoadDialog} className="py-2.5 px-3">
<List className="w-4 h-4 mr-2" />
Einsätze verwalten
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleExport('png')} className="py-2.5 px-3">
<Download className="w-4 h-4 mr-2" />
Als PNG exportieren
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleExport('pdf')} className="py-2.5 px-3">
<Download className="w-4 h-4 mr-2" />
Als PDF exportieren
</DropdownMenuItem>
{onClearAll && (
<DropdownMenuItem onClick={onClearAll} className="py-2.5 px-3 text-destructive focus:text-destructive">
<Eraser className="w-4 h-4 mr-2" />
Zeichnung leeren
</DropdownMenuItem>
)}
{(userRole === 'SERVER_ADMIN' || userRole === 'TENANT_ADMIN') && (
<DropdownMenuItem onClick={() => window.location.href = '/admin'} className="py-2.5 px-3">
<Settings className="w-4 h-4 mr-2" />
Einstellungen
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<div className="flex items-center gap-1 md:gap-2 shrink-0">
{/* Project info - desktop only */}
{project && (
<div className="hidden xl:flex items-center gap-2 text-sm border border-border rounded-lg px-3 py-1.5 bg-muted/30">
{(project as any).einsatzNr && (
<span className="font-mono text-xs bg-primary/10 text-primary px-1.5 py-0.5 rounded">{(project as any).einsatzNr}</span>
)}
<span className="font-semibold truncate max-w-[180px]">{project.title}</span>
{project.location && (
<><span className="text-muted-foreground">|</span><span className="text-muted-foreground truncate max-w-[150px]">{project.location}</span></>
)}
</div>
)}
{/* Clock - desktop only */}
<div className="hidden lg:flex items-center gap-2 text-sm font-medium tabular-nums">
<Clock className="w-4 h-4 text-muted-foreground" />
<span className="text-primary font-bold">{now.toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' })}</span>
</div>
{/* Desktop: individual buttons */}
<Button
variant={isDarkMode ? 'default' : 'outline'}
className="hidden md:flex h-9 px-2 gap-1.5 text-sm font-semibold"
onClick={onToggleTheme}
title={isDarkMode ? 'Tagmodus' : 'Nachtmodus'}
>
{isDarkMode ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
</Button>
<Button
variant="outline"
className="hidden md:flex h-9 px-2"
onClick={onToggleFullscreen}
title={isFullscreen ? 'Vollbild verlassen' : 'Vollbild'}
>
{isFullscreen ? <Minimize className="w-4 h-4" /> : <Maximize className="w-4 h-4" />}
</Button>
<Button
variant={isAuditOpen ? 'default' : 'outline'}
className="hidden md:flex h-9 px-2 relative"
onClick={onToggleAudit}
title="Audit Trail"
>
<ClipboardList className="w-4 h-4" />
{auditLog.length > 0 && (
<span className="absolute -top-1 -right-1 w-4 h-4 bg-primary text-primary-foreground text-[10px] rounded-full flex items-center justify-center font-bold">
{auditLog.length > 99 ? '99' : auditLog.length}
</span>
)}
</Button>
{/* Desktop: User menu dropdown */}
{userName && onLogout && (
<div className="hidden md:flex items-center ml-1 pl-2 border-l border-border">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-9 px-2 gap-1.5 text-sm text-muted-foreground hover:text-foreground">
<User className="w-4 h-4" />
<span className="hidden lg:inline max-w-[100px] truncate">{userName}</span>
</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>
{userRole === 'TENANT_ADMIN' && (
<DropdownMenuItem onClick={() => window.location.href = '/settings'}>
<Building2 className="w-4 h-4 mr-2" />
Organisation
</DropdownMenuItem>
)}
{(userRole === 'SERVER_ADMIN' || userRole === 'TENANT_ADMIN') && (
<DropdownMenuItem onClick={() => window.location.href = '/admin'}>
<Shield className="w-4 h-4 mr-2" />
Administration
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={onLogout} className="text-destructive focus:text-destructive">
<LogOut className="w-4 h-4 mr-2" />
Abmelden
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
{/* Mobile: overflow menu with all secondary actions */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="md:hidden h-9 px-2">
<MoreVertical className="w-5 h-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<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={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={() => setIsHoseSettingsOpen(true)}>
<Settings className="w-4 h-4 mr-2" />
Einstellungen
</DropdownMenuItem>
<DropdownMenuItem onClick={onToggleAudit}>
<ClipboardList className="w-4 h-4 mr-2" />
Audit Trail {auditLog.length > 0 && `(${auditLog.length})`}
</DropdownMenuItem>
{onLogout && (
<DropdownMenuItem onClick={onLogout} className="text-destructive">
<LogOut className="w-4 h-4 mr-2" />
Abmelden {userName && `(${userName})`}
</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>
{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">
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} />
{/* 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>
</header>
)
}