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. - -
- )} - - {/* 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 + + + )} + {user && user.emailVerified === false && ( + + E-Mail unbestätigt + + )} -
- {roleCanEdit && !isEditingByMe && !isReadOnly && ( - - )} - {isEditingByMe && ( - - )} -
)} @@ -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 && ( -
- - {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 ( -
- - {expanded && ( -
-
- {g.symbols.map(symbol => ( - - ))} -
-
- )} -
- ) - }) - )} -
- )} + {!isLoading && groups.length === 0 && ( +
+ Keine Symbole verfügbar. +
+ Eigene Symbole unter Admin → Symbole verwalten.
)} - {/* ─── Section 2: Bibliothek (Global categories) ─── */} -
- - {showLibrarySection && ( - <> - {/* Category tabs */} -
-
- {categories.map((cat) => { - const IconComponent = categoryIcons[cat.name] || Target - return ( - - ) - })} -
-
+ {/* 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 ( + + ) + })} +
+
+ )} - {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) }