diff --git a/package.json b/package.json
index a141600..4bdf044 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "lageplan",
- "version": "1.4.15",
+ "version": "1.4.16",
"description": "Feuerwehr Lageplan - Krokier-App für Einsatzdokumentation",
"private": true,
"scripts": {
diff --git a/src/app/app/page.tsx b/src/app/app/page.tsx
index 5dfe9a7..689c124 100644
--- a/src/app/app/page.tsx
+++ b/src/app/app/page.tsx
@@ -19,7 +19,7 @@ import { useAuth } from '@/components/providers/auth-provider'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { JournalView } from '@/components/journal/journal-view'
-import { Lock, Unlock, Eye, AlertTriangle, WifiOff, GraduationCap, Map as MapIcon, ClipboardList, LayoutGrid, X } from 'lucide-react'
+import { Lock, Eye, AlertTriangle, WifiOff, GraduationCap, Map as MapIcon, ClipboardList, LayoutGrid, X } from 'lucide-react'
import { CustomDragLayer } from '@/components/map/custom-drag-layer'
import { OnboardingTour, resetOnboardingTour } from '@/components/onboarding/onboarding-tour'
import { useKeyboardShortcuts } from '@/hooks/use-keyboard-shortcuts'
@@ -391,6 +391,20 @@ export default function AppPage() {
const canEdit = canEditMap // Abwärtskompatibel für Karten-bezogene Stellen
const isReadOnly = !!editingBy && !isEditingByMe
+ // Auto-Bearbeitung: sobald ein berechtigter Nutzer zu zeichnen beginnt und die Karte
+ // frei ist, wird der Editier-Lock still übernommen — kein manuelles „Bearbeitung starten".
+ const ensureEditing = useCallback(() => {
+ if (!currentProject || presentationLocked || !roleCanEdit) return
+ if (isEditingByMe || editingBy) return // ich zeichne schon / jemand anderes hält den Lock
+ handleStartEditing({ silent: true })
+ }, [currentProject, presentationLocked, roleCanEdit, isEditingByMe, editingBy, handleStartEditing])
+
+ // Werkzeugwechsel auf ein Zeichen-Tool übernimmt vorab den Lock (rechtzeitig vor dem Zeichnen)
+ const handleDrawModeChange = useCallback((mode: DrawMode) => {
+ if (mode !== 'select') ensureEditing()
+ setDrawMode(mode)
+ }, [ensureEditing, setDrawMode])
+
// Features-Version bei Projektwechsel initialisieren
useEffect(() => {
featuresVersionRef.current = (currentProject as any)?.featuresVersion ?? 0
@@ -647,6 +661,7 @@ export default function AppPage() {
}
const handleFeaturesChange = useCallback((newFeatures: DrawFeature[]) => {
+ ensureEditing() // Auto-Bearbeitung: Lock übernehmen, falls noch nicht geschehen
undoStackRef.current.push(featuresRef.current)
redoStackRef.current = []
@@ -666,7 +681,7 @@ export default function AppPage() {
setFeatures(newFeatures)
broadcastFeatures(newFeatures)
- }, [addAudit, broadcastFeatures])
+ }, [addAudit, broadcastFeatures, ensureEditing])
// Wendet den gewählten Linientyp an: setzt properties.lineType und – bei Leitung/
// Rettungsachse – die konventionelle Farbe/Stärke (Normal behält die Zeichenfarbe).
@@ -723,12 +738,13 @@ export default function AppPage() {
// Tap-to-place: Symbol antippen → Sidebar schliessen, Karte zeigen, nächster Tipp platziert
const handleSymbolSelect = useCallback((symbol: { id: string; imageUrl: string }) => {
+ ensureEditing() // Lock schon beim Auswählen übernehmen, damit das Platzieren sofort greift
setPendingSymbol(symbol)
setIsSidebarOpen(false)
setActiveTab('map')
setDrawMode('select')
toast({ title: 'Symbol platzieren', description: 'Tippe auf die Karte, um das Symbol zu setzen.' })
- }, [setIsSidebarOpen, setActiveTab, toast])
+ }, [setIsSidebarOpen, setActiveTab, toast, ensureEditing, setDrawMode])
const handleTextPlace = useCallback((coordinates: [number, number]) => {
setTextPlaceCoords(coordinates)
@@ -909,82 +925,55 @@ export default function AppPage() {
}}
/>
- {/* Übungs-Banner — verhindert Verwechslung mit echtem Einsatz */}
- {currentProject?.mode === 'UEBUNG' && (
-
-
- ÜBUNG — kein realer Einsatz
-
- )}
-
- {/* Offline banner */}
- {isOffline && (
-
-
-
- Offline -Modus — Änderungen werden lokal gespeichert und beim Reconnect synchronisiert.
- {syncQueueCount > 0 && ` (${syncQueueCount} ausstehend)`}
-
-
- )}
-
- {/* Email verification banner */}
- {user && user.emailVerified === false && (
-
-
-
E-Mail noch nicht bestätigt. Bitte prüfen Sie Ihren Posteingang.
-
{
- try {
- const res = await fetch('/api/auth/resend-verification', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ email: user.email }),
- })
- if (res.ok) toast({ title: 'Bestätigungsmail gesendet', description: 'Bitte prüfen Sie Ihren Posteingang.' })
- else toast({ title: 'Fehler', description: 'Konnte Bestätigungsmail nicht senden.', variant: 'destructive' })
- } catch { toast({ title: 'Fehler', description: 'Verbindungsfehler.', variant: 'destructive' }) }
- }}
- className="shrink-0 text-xs font-semibold underline hover:no-underline text-amber-700 dark:text-amber-400"
- >
- Erneut senden
-
-
- )}
-
- {/* Live editing banner */}
- {currentProject && (
-
- {isReadOnly ? (
-
-
- {editingBy?.name} bearbeitet die Karte — Live-Ansicht{roleCanEdit ? ' · Journal bleibt für Sie bearbeitbar' : ''}
-
- ) : isEditingByMe ? (
-
-
- Sie bearbeiten die Karte — andere sehen Ihre Änderungen in Echtzeit
-
- ) : (
-
-
- Niemand bearbeitet gerade
-
+ {/* Kompakte Status-Zeile — eine Zeile mit Chips statt bis zu vier gestapelten Bannern.
+ Zeigt nur, was gerade zutrifft; im Normalfall (Einsatz, online, verifiziert,
+ niemand fremd am Zeichnen) erscheint sie gar nicht. */}
+ {(currentProject?.mode === 'UEBUNG' || isOffline || isReadOnly || isEditingByMe || (user && user.emailVerified === false)) && (
+
+ {currentProject?.mode === 'UEBUNG' && (
+
+ ÜBUNG — kein realer Einsatz
+
+ )}
+ {isOffline && (
+
+ Offline{syncQueueCount > 0 ? ` · ${syncQueueCount} ausstehend` : ''}
+
+ )}
+ {isReadOnly && (
+
+ {editingBy?.name} bearbeitet · Live-Ansicht
+
+ )}
+ {isEditingByMe && (
+
+ Sie bearbeiten
+
+ freigeben
+
+
+ )}
+ {user && user.emailVerified === false && (
+
+ E-Mail unbestätigt
+ {
+ try {
+ const res = await fetch('/api/auth/resend-verification', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ email: user.email }),
+ })
+ if (res.ok) toast({ title: 'Bestätigungsmail gesendet', description: 'Bitte prüfen Sie Ihren Posteingang.' })
+ else toast({ title: 'Fehler', description: 'Konnte Bestätigungsmail nicht senden.', variant: 'destructive' })
+ } catch { toast({ title: 'Fehler', description: 'Verbindungsfehler.', variant: 'destructive' }) }
+ }}
+ className="ml-1 underline hover:no-underline"
+ >
+ senden
+
+
)}
-
- {roleCanEdit && !isEditingByMe && !isReadOnly && (
-
-
- Bearbeitung starten
-
- )}
- {isEditingByMe && (
-
-
- Bearbeitung beenden
-
- )}
-
)}
@@ -993,7 +982,7 @@ export default function AppPage() {
('')
const [categories, setCategories] = useState([])
const [tenantGroups, setTenantGroups] = useState([])
const [isLoading, setIsLoading] = useState(true)
- const [showTenantSection, setShowTenantSection] = useState(true)
- const [showLibrarySection, setShowLibrarySection] = useState(true)
- const [expandedTenantCats, setExpandedTenantCats] = useState>(new Set())
+ const [activeGroupId, setActiveGroupId] = useState('')
useEffect(() => {
async function fetchIcons() {
@@ -139,11 +136,7 @@ export function RightSidebar({ onSymbolDrop, onSymbolSelect, canEdit, isOpen, on
// Separate tenant-specific legacy "Eigene" category from global library
const eigene = allCats.find(c => c.name === 'Eigene')
const globalCats = allCats.filter(c => c.name !== 'Eigene')
-
setCategories(globalCats)
- if (globalCats.length > 0 && !activeCategory) {
- setActiveCategory(globalCats[0].id)
- }
// ─── New tenant symbol groups (Phase 1) ───
const groups: TenantSymbolGroup[] = (data.tenantSymbolGroups || []).map((g: any) => ({
@@ -157,20 +150,10 @@ export function RightSidebar({ onSymbolDrop, onSymbolSelect, canEdit, isOpen, on
// Merge legacy "Eigene" into tenant groups if present
if (eigene && eigene.symbols.length > 0) {
- const legacyGroup: TenantSymbolGroup = {
- category: { id: '__legacy__', name: 'Eigene' },
- symbols: eigene.symbols,
- }
- groups.unshift(legacyGroup)
+ groups.unshift({ category: { id: '__legacy__', name: 'Eigene' }, symbols: eigene.symbols })
}
setTenantGroups(groups)
-
- // Auto-expand all tenant groups, auto-collapse library if tenant has symbols
- if (groups.length > 0 && groups.some(g => g.symbols.length > 0)) {
- setExpandedTenantCats(new Set(groups.map(g => g.category?.id || '__none__')))
- setShowLibrarySection(false)
- }
}
} catch (err) {
console.error('Failed to load icons:', err)
@@ -181,31 +164,35 @@ export function RightSidebar({ onSymbolDrop, onSymbolSelect, canEdit, isOpen, on
fetchIcons()
}, [tenantId])
- const toggleTenantCat = (catId: string | null) => {
- const key = catId || '__none__'
- setExpandedTenantCats(prev => {
- const n = new Set(prev)
- n.has(key) ? n.delete(key) : n.add(key)
- return n
- })
- }
+ // ─── Flache Gruppen-Liste: Mandanten-Kategorien zuerst, dann Bibliothek — je ein Chip.
+ // Ersetzt die früheren verschachtelten Klapp-Sektionen (Meine Symbole / Bibliothek). ───
+ type SymbolGroup = { id: string; name: string; symbols: DisplaySymbol[]; isTenant: boolean }
+ const groups: SymbolGroup[] = [
+ ...tenantGroups.map(g => ({
+ id: 'tenant:' + (g.category?.id || 'none'),
+ name: g.category?.name || 'Eigene',
+ symbols: g.symbols,
+ isTenant: true,
+ })),
+ ...categories.map(c => ({ id: 'lib:' + c.id, name: c.name, symbols: c.symbols, isTenant: false })),
+ ].filter(g => g.symbols.length > 0)
- const filteredCategories = categories.map((cat) => ({
- ...cat,
- symbols: cat.symbols.filter((s) =>
- (s.name || '').toLowerCase().includes(searchQuery.toLowerCase())
- ),
- }))
- const filteredTenantGroups = tenantGroups.map(g => ({
- ...g,
- symbols: g.symbols.filter(s =>
- (s.name || '').toLowerCase().includes(searchQuery.toLowerCase())
- ),
- })).filter(g => g.symbols.length > 0)
+ const totalSymbols = groups.reduce((sum, g) => sum + g.symbols.length, 0)
- const currentCategory = filteredCategories.find((c) => c.id === activeCategory)
- const totalSymbols = categories.reduce((sum, c) => sum + c.symbols.length, 0) +
- tenantGroups.reduce((sum, g) => sum + g.symbols.length, 0)
+ const q = searchQuery.trim().toLowerCase()
+ const isSearching = q.length > 0
+ const searchResults = isSearching
+ ? groups.flatMap(g => g.symbols).filter(s => (s.name || '').toLowerCase().includes(q))
+ : []
+
+ const activeGroup = groups.find(g => g.id === activeGroupId) || groups[0] || null
+
+ // Default-Gruppe wählen bzw. korrigieren, falls die aktuelle nicht mehr existiert
+ useEffect(() => {
+ if (groups.length && !groups.some(g => g.id === activeGroupId)) {
+ setActiveGroupId(groups[0].id)
+ }
+ }, [groups, activeGroupId])
return (
<>
@@ -325,121 +312,59 @@ export function RightSidebar({ onSymbolDrop, onSymbolSelect, canEdit, isOpen, on
)}
- {/* ─── Section 1: Meine Symbole (Tenant Symbol Groups) ─── */}
- {tenantId && (
-
-
setShowTenantSection(!showTenantSection)}
- className="w-full flex items-center justify-between px-2 py-1.5 text-xs font-semibold text-muted-foreground hover:bg-accent/50 transition-colors"
- >
-
-
- Meine Symbole ({tenantGroups.reduce((s, g) => s + g.symbols.length, 0)})
-
-
-
- {showTenantSection && (
-
- {filteredTenantGroups.length === 0 ? (
-
- Keine eigenen Symbole vorhanden.
-
- Symbole können unter Admin → Symbole verwaltet werden.
-
- ) : (
- filteredTenantGroups.map(g => {
- const key = g.category?.id || '__none__'
- const expanded = expandedTenantCats.has(key)
- return (
-
-
toggleTenantCat(g.category?.id || null)}
- className="w-full flex items-center justify-between px-2 py-1 text-[11px] font-medium hover:bg-muted/40 transition-colors"
- >
-
-
- {g.category?.name || 'Ohne Kategorie'}
- ({g.symbols.length})
-
-
-
- {expanded && (
-
-
- {g.symbols.map(symbol => (
-
- ))}
-
-
- )}
-
- )
- })
- )}
-
- )}
+ {!isLoading && groups.length === 0 && (
+
+ Keine Symbole verfügbar.
+
+ Eigene Symbole unter Admin → Symbole verwalten.
)}
- {/* ─── Section 2: Bibliothek (Global categories) ─── */}
-
-
setShowLibrarySection(!showLibrarySection)}
- className="w-full flex items-center justify-between px-2 py-1.5 text-xs font-semibold text-muted-foreground hover:bg-accent/50 transition-colors"
- >
-
-
- Bibliothek ({categories.reduce((s, c) => s + c.symbols.length, 0)})
-
-
-
- {showLibrarySection && (
- <>
- {/* Category tabs */}
-
-
- {categories.map((cat) => {
- const IconComponent = categoryIcons[cat.name] || Target
- return (
- setActiveCategory(cat.id)}
- className={`
- flex items-center gap-1 px-2 py-1.5 md:px-2.5 md:py-1.5 lg:px-3 lg:py-2 rounded-md md:rounded-lg text-xs md:text-sm font-medium transition-colors
- ${activeCategory === cat.id
- ? 'bg-primary text-primary-foreground'
- : 'bg-muted hover:bg-accent text-muted-foreground'}
- `}
- title={`${cat.name} (${cat.symbols.length})`}
- >
-
- {cat.name}
-
- )
- })}
-
-
+ {/* Kategorie-Chips (Mandant + Bibliothek in einer Reihe) — nur eine Ebene */}
+ {!isSearching && groups.length > 0 && (
+
+
+ {groups.map(g => {
+ const IconComponent = g.isTenant ? FolderOpen : (categoryIcons[g.name] || Target)
+ const active = activeGroup?.id === g.id
+ return (
+ setActiveGroupId(g.id)}
+ className={`flex items-center gap-1 px-2 py-1.5 rounded-md text-xs font-medium transition-colors ${
+ active ? 'bg-primary text-primary-foreground' : 'bg-muted hover:bg-accent text-muted-foreground'
+ }`}
+ title={`${g.name} (${g.symbols.length})`}
+ >
+
+ {g.name}
+ {g.symbols.length}
+
+ )
+ })}
+
+
+ )}
- {currentCategory && (
-
-
- {currentCategory.name} ({currentCategory.symbols.length})
-
- {currentCategory.symbols.length === 0 ? (
-
- Keine Symbole gefunden
-
- ) : (
-
- {currentCategory.symbols.map((symbol) => (
-
- ))}
-
- )}
-
- )}
- >
- )}
+ {/* Symbol-Raster: Suchergebnisse ODER aktive Kategorie */}
+
+ {isSearching ? (
+ searchResults.length === 0 ? (
+
Keine Symbole gefunden
+ ) : (
+
+ {searchResults.map(symbol => (
+
+ ))}
+
+ )
+ ) : activeGroup ? (
+
+ {activeGroup.symbols.map(symbol => (
+
+ ))}
+
+ ) : null}
>
diff --git a/src/hooks/use-realtime-sync.ts b/src/hooks/use-realtime-sync.ts
index 6fc9bc5..ce1e16f 100644
--- a/src/hooks/use-realtime-sync.ts
+++ b/src/hooks/use-realtime-sync.ts
@@ -186,8 +186,9 @@ export function useRealtimeSync({
}
}, [currentProject?.id, isEditingByMe])
- const handleStartEditing = useCallback(async () => {
- if (!currentProject?.id) return
+ // Rückgabe true = Lock erhalten. `silent` unterdrückt den Erfolgs-Toast (für Auto-Bearbeitung).
+ const handleStartEditing = useCallback(async (opts?: { silent?: boolean }): Promise
=> {
+ if (!currentProject?.id) return false
setEditingLoading(true)
try {
const res = await fetch(`/api/projects/${currentProject.id}/editing`, {
@@ -198,7 +199,7 @@ export function useRealtimeSync({
if (!res.ok) {
const data = await res.json()
toast({ title: 'Gesperrt', description: data.error || 'Bearbeitung nicht möglich', variant: 'destructive' })
- return
+ return false
}
setIsEditingByMe(true)
const editingInfo = { id: user!.id, name: user!.name, since: new Date().toISOString() }
@@ -210,9 +211,13 @@ export function useRealtimeSync({
editingBy: editingInfo,
sessionId: sessionIdRef.current,
})
- toast({ title: 'Bearbeitung gestartet', description: 'Sie können jetzt zeichnen und Einträge erstellen.' })
+ if (!opts?.silent) {
+ toast({ title: 'Bearbeitung gestartet', description: 'Sie können jetzt zeichnen und Einträge erstellen.' })
+ }
+ return true
} catch (e) {
toast({ title: 'Fehler', description: 'Konnte Bearbeitung nicht starten.', variant: 'destructive' })
+ return false
} finally {
setEditingLoading(false)
}