diff --git a/package-lock.json b/package-lock.json
index cfa617e..a66f475 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "lageplan",
- "version": "1.0.1",
+ "version": "1.2.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "lageplan",
- "version": "1.0.1",
+ "version": "1.2.2",
"hasInstallScript": true,
"dependencies": {
"@dnd-kit/core": "^6.1.0",
@@ -53,7 +53,8 @@
"tailwind-merge": "^2.2.1",
"tailwindcss-animate": "^1.0.7",
"uuid": "^9.0.1",
- "zod": "^3.22.4"
+ "zod": "^3.22.4",
+ "zustand": "^5.0.11"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
@@ -10566,6 +10567,35 @@
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
+ },
+ "node_modules/zustand": {
+ "version": "5.0.11",
+ "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.11.tgz",
+ "integrity": "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20.0"
+ },
+ "peerDependencies": {
+ "@types/react": ">=18.0.0",
+ "immer": ">=9.0.6",
+ "react": ">=18.0.0",
+ "use-sync-external-store": ">=1.2.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "immer": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ },
+ "use-sync-external-store": {
+ "optional": true
+ }
+ }
}
}
}
diff --git a/package.json b/package.json
index fa6c274..a6facb1 100644
--- a/package.json
+++ b/package.json
@@ -61,7 +61,8 @@
"tailwind-merge": "^2.2.1",
"tailwindcss-animate": "^1.0.7",
"uuid": "^9.0.1",
- "zod": "^3.22.4"
+ "zod": "^3.22.4",
+ "zustand": "^5.0.11"
},
"prisma": {
"seed": "node prisma/seed.js"
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 0a5ef21..9ed69b5 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -378,11 +378,13 @@ model UpgradeRequest {
@@map("upgrade_requests")
}
-// ─── Tenant Symbol Visibility ─────────────────────────────
+// ─── Tenant Symbol Collection ─────────────────────────────
model TenantSymbol {
- id String @id @default(uuid())
- isActive Boolean @default(true)
+ id String @id @default(uuid())
+ customName String?
+ sortOrder Int @default(0)
+ createdAt DateTime @default(now())
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
@@ -390,7 +392,7 @@ model TenantSymbol {
iconId String
icon IconAsset @relation(fields: [iconId], references: [id], onDelete: Cascade)
- @@unique([tenantId, iconId])
+ @@index([tenantId])
@@map("tenant_symbols")
}
diff --git a/src/app/admin/error.tsx b/src/app/admin/error.tsx
new file mode 100644
index 0000000..95853c7
--- /dev/null
+++ b/src/app/admin/error.tsx
@@ -0,0 +1,35 @@
+'use client'
+
+import { AlertTriangle, RotateCcw, Home } from 'lucide-react'
+import { Button } from '@/components/ui/button'
+import Link from 'next/link'
+
+export default function AdminError({
+ error,
+ reset,
+}: {
+ error: Error & { digest?: string }
+ reset: () => void
+}) {
+ return (
+
+
+
Fehler im Admin-Bereich
+
+ {error.message || 'Ein unerwarteter Fehler ist aufgetreten. Bitte versuche es erneut.'}
+
+
+
+
+ Erneut versuchen
+
+
+
+
+ Zur App
+
+
+
+
+ )
+}
diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx
index 26e227b..f143388 100644
--- a/src/app/admin/page.tsx
+++ b/src/app/admin/page.tsx
@@ -40,12 +40,9 @@ import {
Layers,
Loader2,
MapPin,
- Mail,
- Send,
CheckCircle,
Ban,
Clock,
- CreditCard,
KeyRound,
Copy,
Heart,
@@ -54,14 +51,17 @@ import {
ClipboardList,
X,
BookOpen,
- Download,
AlertTriangle,
- GripVertical,
LayoutGrid,
} from 'lucide-react'
import Link from 'next/link'
import { TenantDetailDialog } from '@/components/admin/tenant-detail-dialog'
import { HoseSettingsDialog } from '@/components/dialogs/hose-settings-dialog'
+import { SettingsTab } from '@/components/admin/settings-tab'
+import { SomaTab } from '@/components/admin/soma-tab'
+import { SuggestionsTab } from '@/components/admin/suggestions-tab'
+import { DictionaryTab } from '@/components/admin/dictionary-tab'
+import { SymbolManager } from '@/components/admin/symbol-manager'
// --- Types ---
interface IconCategory {
@@ -176,47 +176,8 @@ export default function AdminPage() {
const [selectedTenantId, setSelectedTenantId] = useState(null)
const [isTenantDetailOpen, setIsTenantDetailOpen] = useState(false)
- // SMTP Settings
- const [smtpHost, setSmtpHost] = useState('')
- const [smtpPort, setSmtpPort] = useState('587')
- const [smtpSecure, setSmtpSecure] = useState(false)
- const [smtpUser, setSmtpUser] = useState('')
- const [smtpPass, setSmtpPass] = useState('')
- const [smtpFromName, setSmtpFromName] = useState('Lageplan')
- const [smtpFromEmail, setSmtpFromEmail] = useState('')
- const [smtpTestEmail, setSmtpTestEmail] = useState('')
- const [smtpLoading, setSmtpLoading] = useState(false)
- const [smtpStatus, setSmtpStatus] = useState(null)
- const [contactEmail, setContactEmail] = useState('app@lageplan.ch')
- const [notifyRegistrationEmail, setNotifyRegistrationEmail] = useState('')
- // Stripe Settings
- const [stripePublicKey, setStripePublicKey] = useState('')
- const [stripeSecretKey, setStripeSecretKey] = useState('')
- const [stripeWebhookSecret, setStripeWebhookSecret] = useState('')
- const [stripeLoading, setStripeLoading] = useState(false)
- const [stripeStatus, setStripeStatus] = useState(null)
- // Journal Suggestions (word library)
- const [journalSuggestions, setJournalSuggestions] = useState([])
- const [newSuggestion, setNewSuggestion] = useState('')
- const [suggestionsLoading, setSuggestionsLoading] = useState(false)
-
- // Global Dictionary (SERVER_ADMIN)
- const [globalDictWords, setGlobalDictWords] = useState<{ id: string; word: string; scope: string }[]>([])
- const [newGlobalWord, setNewGlobalWord] = useState('')
- const [dictLoading, setDictLoading] = useState(false)
-
- // Demo Project
- const [demoProjectId, setDemoProjectId] = useState('')
- const [allProjects, setAllProjects] = useState<{ id: string; title: string; location?: string }[]>([])
- const [demoLoading, setDemoLoading] = useState(false)
- const [demoStatus, setDemoStatus] = useState(null)
-
- // Default Symbol Scale
- const [defaultSymbolScale, setDefaultSymbolScale] = useState('1.5')
- const [symbolScaleLoading, setSymbolScaleLoading] = useState(false)
- const [symbolScaleStatus, setSymbolScaleStatus] = useState(null)
// Admin Projects (SERVER_ADMIN)
const [adminProjects, setAdminProjects] = useState([])
@@ -226,18 +187,7 @@ export default function AdminPage() {
// Hose Settings (Tenant Admin)
const [isHoseSettingsOpen, setIsHoseSettingsOpen] = useState(false)
- // SOMA Templates (Tenant Admin)
- const [somaTemplates, setSomaTemplates] = useState<{ id: string; label: string; sortOrder: number; isActive: boolean }[]>([])
- const [newSomaLabel, setNewSomaLabel] = useState('')
- const [somaLoading, setSomaLoading] = useState(false)
- // Symbol Management (Tenant Admin)
- const [tenantSymbols, setTenantSymbols] = useState<{ id: string; name: string; fileKey: string; mimeType: string; iconType: string; categoryId: string | null; categoryName: string; isActive: boolean }[]>([])
- const [symbolSearch, setSymbolSearch] = useState('')
- const [symbolCatFilter, setSymbolCatFilter] = useState('all')
- const [symbolViewTab, setSymbolViewTab] = useState<'library' | 'active'>('library')
- const [selectedSymbolIds, setSelectedSymbolIds] = useState>(new Set())
- const [symbolsLoading, setSymbolsLoading] = useState(false)
// Redirect to login if not authenticated, or to app if not admin
useEffect(() => {
@@ -253,60 +203,9 @@ export default function AdminPage() {
if (user?.role) fetchData()
}, [user?.role])
- // Load journal suggestions when tenant is available
- useEffect(() => {
- if (!tenant?.id) return
- fetch(`/api/tenants/${tenant.id}/suggestions`)
- .then(r => r.ok ? r.json() : null)
- .then(data => { if (data?.suggestions) setJournalSuggestions(data.suggestions) })
- .catch(() => {})
- }, [tenant?.id])
- // Load SOMA templates (TENANT_ADMIN)
- const fetchSomaTemplates = async () => {
- setSomaLoading(true)
- try {
- const res = await fetch('/api/tenant/soma-templates')
- if (res.ok) {
- const data = await res.json()
- setSomaTemplates(data.templates || [])
- }
- } catch {}
- setSomaLoading(false)
- }
- useEffect(() => {
- if (user?.role === 'TENANT_ADMIN') fetchSomaTemplates()
- }, [user?.role])
- // Load tenant symbols (TENANT_ADMIN)
- const fetchTenantSymbols = async () => {
- setSymbolsLoading(true)
- try {
- const res = await fetch('/api/tenant/symbols')
- if (res.ok) {
- const data = await res.json()
- setTenantSymbols(data.symbols || [])
- }
- } catch {}
- setSymbolsLoading(false)
- }
- useEffect(() => {
- if (user?.role === 'TENANT_ADMIN') fetchTenantSymbols()
- }, [user?.role])
- // Load global dictionary (SERVER_ADMIN)
- const fetchGlobalDict = async () => {
- try {
- const res = await fetch('/api/dictionary')
- if (res.ok) {
- const data = await res.json()
- setGlobalDictWords((data.words || []).filter((w: any) => w.scope === 'GLOBAL'))
- }
- } catch {}
- }
- useEffect(() => {
- if (user?.role === 'SERVER_ADMIN') fetchGlobalDict()
- }, [user?.role])
// Fetch admin projects (SERVER_ADMIN)
const fetchAdminProjects = async (tenantFilter?: string) => {
@@ -336,7 +235,6 @@ export default function AdminPage() {
const fetches: Promise[] = [
fetch('/api/admin/categories'),
fetch('/api/admin/users'),
- fetch('/api/projects'),
]
// SERVER_ADMIN-only fetches
if (isServerAdmin) {
@@ -345,49 +243,18 @@ export default function AdminPage() {
}
const results = await Promise.all(fetches)
- const [catRes, userRes, projRes] = results
+ const [catRes, userRes] = results
if (catRes.ok) setCategories((await catRes.json()).categories || [])
if (userRes.ok) setUsers((await userRes.json()).users || [])
- if (projRes.ok) {
- const projData = await projRes.json()
- setAllProjects((projData.projects || []).map((p: any) => ({ id: p.id, title: p.title, location: p.location })))
- }
if (isServerAdmin) {
- const iconRes = results[3]
- const tenantRes = results[4]
+ const iconRes = results[2]
+ const tenantRes = results[3]
if (iconRes.ok) setIcons((await iconRes.json()).icons || [])
if (tenantRes.ok) setTenants((await tenantRes.json()).tenants || [])
}
- // Load settings (SERVER_ADMIN only)
- if (isServerAdmin) {
- try {
- const smtpRes = await fetch('/api/admin/settings')
- if (smtpRes.ok) {
- const smtpData = await smtpRes.json()
- if (smtpData.smtp) {
- setSmtpHost(smtpData.smtp.host || '')
- setSmtpPort(String(smtpData.smtp.port || 587))
- setSmtpSecure(smtpData.smtp.secure || false)
- setSmtpUser(smtpData.smtp.user || '')
- setSmtpPass(smtpData.smtp.pass || '')
- setSmtpFromName(smtpData.smtp.fromName || 'Lageplan')
- setSmtpFromEmail(smtpData.smtp.fromEmail || '')
- }
- if (smtpData.contactEmail) setContactEmail(smtpData.contactEmail)
- if (smtpData.notifyRegistrationEmail) setNotifyRegistrationEmail(smtpData.notifyRegistrationEmail)
- if (smtpData.demoProjectId) setDemoProjectId(smtpData.demoProjectId)
- if (smtpData.defaultSymbolScale) setDefaultSymbolScale(smtpData.defaultSymbolScale)
- if (smtpData.stripe) {
- setStripePublicKey(smtpData.stripe.publicKey || '')
- setStripeSecretKey(smtpData.stripe.secretKey || '')
- setStripeWebhookSecret(smtpData.stripe.webhookSecret || '')
- }
- }
- } catch {}
- }
} catch (error) {
console.error('Error fetching data:', error)
} finally {
@@ -395,110 +262,6 @@ export default function AdminPage() {
}
}
- const handleSmtpSave = async () => {
- setSmtpLoading(true)
- setSmtpStatus(null)
- try {
- const res = await fetch('/api/admin/settings', {
- method: 'PUT',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- action: 'save_smtp',
- smtp: { host: smtpHost, port: parseInt(smtpPort), secure: smtpSecure, user: smtpUser, pass: smtpPass, fromName: smtpFromName, fromEmail: smtpFromEmail },
- }),
- })
- const data = await res.json()
- if (data.success) {
- toast({ title: 'SMTP gespeichert' })
- setSmtpStatus('saved')
- } else throw new Error(data.error)
- } catch (error) {
- toast({ title: 'Fehler', description: error instanceof Error ? error.message : 'Fehler', variant: 'destructive' })
- } finally { setSmtpLoading(false) }
- }
-
- const handleSmtpTest = async () => {
- setSmtpLoading(true)
- setSmtpStatus(null)
- try {
- const res = await fetch('/api/admin/settings', {
- method: 'PUT',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ action: 'test_smtp' }),
- })
- const data = await res.json()
- if (data.success) {
- setSmtpStatus('connected')
- toast({ title: 'SMTP-Verbindung erfolgreich' })
- } else {
- setSmtpStatus('error')
- toast({ title: 'Verbindung fehlgeschlagen', description: data.error, variant: 'destructive' })
- }
- } catch (error) {
- setSmtpStatus('error')
- toast({ title: 'Fehler', variant: 'destructive' })
- } finally { setSmtpLoading(false) }
- }
-
- const handleSmtpSendTest = async () => {
- if (!smtpTestEmail) return
- setSmtpLoading(true)
- try {
- const res = await fetch('/api/admin/settings', {
- method: 'PUT',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ action: 'send_test_email', testEmail: smtpTestEmail }),
- })
- const data = await res.json()
- if (data.success) toast({ title: data.message })
- else toast({ title: 'Senden fehlgeschlagen', description: data.error, variant: 'destructive' })
- } catch { toast({ title: 'Fehler', variant: 'destructive' }) }
- finally { setSmtpLoading(false) }
- }
-
- const handleContactEmailSave = async () => {
- setSmtpLoading(true)
- try {
- const res = await fetch('/api/admin/settings', {
- method: 'PUT',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ action: 'save_contact_email', contactEmail }),
- })
- const data = await res.json()
- if (data.success) toast({ title: 'Kontakt-E-Mail gespeichert' })
- else throw new Error(data.error)
- } catch (error) {
- toast({ title: 'Fehler', description: error instanceof Error ? error.message : 'Fehler', variant: 'destructive' })
- } finally { setSmtpLoading(false) }
- }
-
- const handleStripeSave = async () => {
- setStripeLoading(true)
- setStripeStatus(null)
- try {
- const res = await fetch('/api/admin/settings', {
- method: 'PUT',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- action: 'save_stripe',
- stripe: {
- publicKey: stripePublicKey,
- secretKey: stripeSecretKey,
- webhookSecret: stripeWebhookSecret,
- },
- }),
- })
- const data = await res.json()
- if (data.success) {
- setStripeStatus('saved')
- toast({ title: 'Stripe-Einstellungen gespeichert' })
- } else throw new Error(data.error)
- } catch (error) {
- setStripeStatus('error')
- toast({ title: 'Fehler', description: error instanceof Error ? error.message : 'Fehler', variant: 'destructive' })
- } finally { setStripeLoading(false) }
- }
-
// ===== CATEGORY FUNCTIONS =====
const handleSaveCategory = async () => {
try {
@@ -552,8 +315,7 @@ export default function AdminPage() {
setUploadFiles(null)
setUploadCategory('')
setUploadIconName('')
- if (user?.role === 'TENANT_ADMIN') fetchTenantSymbols()
- else fetchData()
+ fetchData()
} catch (error) {
toast({ title: 'Upload-Fehler', description: error instanceof Error ? error.message : 'Fehler', variant: 'destructive' })
} finally { setIsUploading(false) }
@@ -842,161 +604,9 @@ export default function AdminPage() {
{/* ===== ICONS TAB ===== */}
- {user?.role === 'TENANT_ADMIN' ? (() => {
- // --- Tenant Symbol Management UI ---
- const symbolCategories = [...new Set(tenantSymbols.map(s => s.categoryName))].sort()
- const viewSymbols = tenantSymbols
- .filter(s => symbolViewTab === 'active' ? s.isActive : true)
- .filter(s => symbolCatFilter === 'all' || s.categoryName === symbolCatFilter)
- .filter(s => !symbolSearch || s.name.toLowerCase().includes(symbolSearch.toLowerCase()))
- const grouped = viewSymbols.reduce>((acc, s) => {
- const key = s.categoryName
- if (!acc[key]) acc[key] = []
- acc[key].push(s)
- return acc
- }, {})
- const toggleSelect = (id: string) => {
- setSelectedSymbolIds(prev => {
- const next = new Set(prev)
- next.has(id) ? next.delete(id) : next.add(id)
- return next
- })
- }
- const bulkUpdate = async (ids: string[], isActive: boolean) => {
- try {
- await fetch('/api/tenant/symbols', {
- method: 'PATCH',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ updates: ids.map(iconId => ({ iconId, isActive })) }),
- })
- setSelectedSymbolIds(new Set())
- fetchTenantSymbols()
- toast({ title: isActive ? 'Symbole aktiviert' : 'Symbole deaktiviert' })
- } catch {}
- }
-
- return (
- <>
- {/* Header: View tabs + search + filter */}
-
-
- { setSymbolViewTab('library'); setSelectedSymbolIds(new Set()) }}
- >
- Bibliothek
-
- { setSymbolViewTab('active'); setSelectedSymbolIds(new Set()) }}
- >
- Aktive
-
-
-
setSymbolSearch(e.target.value)}
- className="w-full sm:w-64"
- />
-
-
-
-
-
- Alle Kategorien
- {symbolCategories.map(cat => (
- {cat}
- ))}
-
-
-
- {tenantSymbols.filter(s => s.isActive).length} aktiv / {tenantSymbols.length} gesamt
-
-
setIsUploadDialogOpen(true)}>
-
- Eigene Symbole hochladen
-
-
-
- {/* Bulk category action */}
- {symbolCatFilter !== 'all' && (
-
- {
- const ids = tenantSymbols.filter(s => s.categoryName === symbolCatFilter).map(s => s.id)
- bulkUpdate(ids, true)
- }}>
- Alle «{symbolCatFilter}» aktivieren
-
- {
- const ids = tenantSymbols.filter(s => s.categoryName === symbolCatFilter).map(s => s.id)
- bulkUpdate(ids, false)
- }}>
- Alle «{symbolCatFilter}» deaktivieren
-
-
- )}
-
- {/* Selection action bar */}
- {selectedSymbolIds.size > 0 && (
-
- {selectedSymbolIds.size} ausgewählt
- bulkUpdate([...selectedSymbolIds], true)}>
- Aktivieren
-
- bulkUpdate([...selectedSymbolIds], false)}>
- Deaktivieren
-
- setSelectedSymbolIds(new Set())}>
-
-
-
- )}
-
- {/* Symbol grid grouped by category */}
- {symbolsLoading ? (
-
- Symbole laden...
-
- ) : viewSymbols.length === 0 ? (
-
- {symbolViewTab === 'active' ? 'Keine aktiven Symbole.' : 'Keine Symbole gefunden.'}
-
- ) : (
- Object.entries(grouped).sort(([a], [b]) => a.localeCompare(b)).map(([catName, syms]) => (
-
-
- {catName}
- ({syms.length})
-
-
- {syms.map(sym => {
- const selected = selectedSymbolIds.has(sym.id)
- return (
-
toggleSelect(sym.id)}
- className={`relative cursor-pointer border-2 rounded-lg p-3 transition-all hover:shadow-sm ${
- selected ? 'border-blue-500 bg-blue-50 dark:bg-blue-950/30' :
- sym.isActive ? 'border-transparent hover:border-border' : 'border-transparent opacity-40'
- }`}
- >
-
-
-
-
{sym.name}
- {/* Status dot */}
-
-
- )
- })}
-
-
- ))
- )}
- >
- )
- })() : (
+ {user?.role === 'TENANT_ADMIN' ? (
+
+ ) : (
/* --- SERVER_ADMIN: existing icon management --- */
<>
@@ -1318,522 +928,25 @@ export default function AdminPage() {
{/* ===== SUGGESTIONS TAB (Word Library) ===== */}
-
-
-
- Journal-Wörterliste
-
-
- Häufige Begriffe und Textbausteine, die beim Erfassen von Journal-Einträgen als Vorschläge erscheinen.
- Wenn der Benutzer im "Was..."-Feld tippt, werden passende Begriffe vorgeschlagen.
-
-
- {/* Add new suggestion */}
-
-
setNewSuggestion(e.target.value)}
- onKeyDown={(e) => {
- if (e.key === 'Enter' && newSuggestion.trim()) {
- const trimmed = newSuggestion.trim()
- if (!journalSuggestions.includes(trimmed)) {
- const updated = [...journalSuggestions, trimmed].sort((a, b) => a.localeCompare(b, 'de'))
- setJournalSuggestions(updated)
- setNewSuggestion('')
- // Auto-save
- if (tenant?.id) {
- fetch(`/api/tenants/${tenant.id}/suggestions`, {
- method: 'PUT',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ suggestions: updated }),
- }).then(r => { if (r.ok) toast({ title: 'Gespeichert' }) })
- }
- }
- }
- }}
- className="flex-1"
- />
-
{
- const trimmed = newSuggestion.trim()
- if (!trimmed || journalSuggestions.includes(trimmed)) return
- const updated = [...journalSuggestions, trimmed].sort((a, b) => a.localeCompare(b, 'de'))
- setJournalSuggestions(updated)
- setNewSuggestion('')
- if (tenant?.id) {
- fetch(`/api/tenants/${tenant.id}/suggestions`, {
- method: 'PUT',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ suggestions: updated }),
- }).then(r => { if (r.ok) toast({ title: 'Gespeichert' }) })
- }
- }}
- disabled={!newSuggestion.trim()}
- >
-
- Hinzufügen
-
-
-
- {/* List of suggestions */}
- {journalSuggestions.length === 0 ? (
-
- Noch keine Begriffe hinterlegt. Fügen Sie häufig verwendete Textbausteine hinzu,
- z.B. "Leitung aufbauen", "Leitung abbauen", "Lüfter in Stellung", etc.
-
- ) : (
-
- {journalSuggestions.map((s, i) => (
-
- {s}
- {
- const updated = journalSuggestions.filter((_, idx) => idx !== i)
- setJournalSuggestions(updated)
- if (tenant?.id) {
- fetch(`/api/tenants/${tenant.id}/suggestions`, {
- method: 'PUT',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ suggestions: updated }),
- }).then(r => { if (r.ok) toast({ title: 'Entfernt' }) })
- }
- }}
- className="ml-1 hover:text-red-500 transition-colors"
- >
-
-
-
- ))}
-
- )}
-
-
-
- {journalSuggestions.length} Begriff(e) hinterlegt. Änderungen werden automatisch gespeichert.
-
-
{
- const blob = new Blob([journalSuggestions.join('\n')], { type: 'text/plain' })
- const url = URL.createObjectURL(blob)
- const a = document.createElement('a')
- a.href = url
- a.download = 'woerterliste.txt'
- a.click()
- URL.revokeObjectURL(url)
- toast({ title: 'Exportiert', description: `${journalSuggestions.length} Begriffe exportiert` })
- }}
- disabled={journalSuggestions.length === 0}
- >
-
- Export
-
-
{
- const input = document.createElement('input')
- input.type = 'file'
- input.accept = '.txt,.csv'
- input.onchange = async (e) => {
- const file = (e.target as HTMLInputElement).files?.[0]
- if (!file) return
- const text = await file.text()
- const words = text.split(/[\n\r,;]+/).map(w => w.trim()).filter(Boolean)
- if (words.length === 0) { toast({ title: 'Keine Begriffe gefunden', variant: 'destructive' }); return }
- const merged = Array.from(new Set([...journalSuggestions, ...words])).sort((a, b) => a.localeCompare(b, 'de'))
- setJournalSuggestions(merged)
- if (tenant?.id) {
- const res = await fetch(`/api/tenants/${tenant.id}/suggestions`, {
- method: 'PUT',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ suggestions: merged }),
- })
- if (res.ok) toast({ title: 'Importiert', description: `${words.length} Begriffe importiert (${merged.length} total)` })
- }
- }
- input.click()
- }}
- >
-
- Import
-
-
-
+
{/* ===== DICTIONARY TAB (SERVER_ADMIN — Global Word Library) ===== */}
{user?.role === 'SERVER_ADMIN' && (
-
-
-
- Globales Wörterbuch
-
-
- Globale Begriffe, die allen Mandanten als Journal-Vorschläge zur Verfügung stehen.
- Mandanten können zusätzlich eigene Begriffe über ihre Wörterliste hinzufügen.
-
-
- {/* Add new word */}
-
-
setNewGlobalWord(e.target.value)}
- onKeyDown={async (e) => {
- if (e.key === 'Enter' && newGlobalWord.trim()) {
- setDictLoading(true)
- try {
- const res = await fetch('/api/dictionary', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ word: newGlobalWord.trim(), scope: 'GLOBAL' }),
- })
- if (res.ok) {
- setNewGlobalWord('')
- fetchGlobalDict()
- toast({ title: 'Begriff hinzugefügt' })
- } else {
- const data = await res.json()
- toast({ title: 'Fehler', description: data.error, variant: 'destructive' })
- }
- } catch { toast({ title: 'Fehler', variant: 'destructive' }) }
- finally { setDictLoading(false) }
- }
- }}
- className="flex-1"
- disabled={dictLoading}
- />
-
{
- if (!newGlobalWord.trim()) return
- setDictLoading(true)
- try {
- const res = await fetch('/api/dictionary', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ word: newGlobalWord.trim(), scope: 'GLOBAL' }),
- })
- if (res.ok) {
- setNewGlobalWord('')
- fetchGlobalDict()
- toast({ title: 'Begriff hinzugefügt' })
- } else {
- const data = await res.json()
- toast({ title: 'Fehler', description: data.error, variant: 'destructive' })
- }
- } catch { toast({ title: 'Fehler', variant: 'destructive' }) }
- finally { setDictLoading(false) }
- }}
- disabled={!newGlobalWord.trim() || dictLoading}
- >
-
- Hinzufügen
-
-
-
- {/* List of global words */}
- {globalDictWords.length === 0 ? (
-
- Noch keine globalen Begriffe hinterlegt.
-
- ) : (
-
- {globalDictWords.map((w) => (
-
- {w.word}
- {
- try {
- const res = await fetch(`/api/dictionary/${w.id}`, { method: 'DELETE' })
- if (res.ok) {
- fetchGlobalDict()
- toast({ title: 'Entfernt' })
- }
- } catch { toast({ title: 'Fehler', variant: 'destructive' }) }
- }}
- className="ml-1 hover:text-red-500 transition-colors"
- >
-
-
-
- ))}
-
- )}
-
-
- {globalDictWords.length} globale(r) Begriff(e). Diese erscheinen bei allen Mandanten als Vorschläge im Journal.
-
-
+
)}
{/* ===== SETTINGS TAB (SERVER_ADMIN only) ===== */}
{user?.role === 'SERVER_ADMIN' && (
-
- {/* Contact Email */}
-
-
-
- Kontakt-E-Mail
-
-
E-Mail-Adresse für das Kontaktformular auf der Landing Page. Hierhin werden Anfragen gesendet.
-
-
Empfänger-Adresse setContactEmail(e.target.value)} placeholder="app@lageplan.ch" />
-
Speichern
-
-
-
- {/* Registration Notification */}
-
-
-
- Registrierungs-Benachrichtigung
-
-
E-Mail-Adresse, an die bei neuen Registrierungen eine Benachrichtigung gesendet wird. Leer lassen = keine Benachrichtigung.
-
-
Admin-E-Mail setNotifyRegistrationEmail(e.target.value)} placeholder="admin@lageplan.ch" />
-
{
- try {
- const res = await fetch('/api/admin/settings', {
- method: 'PUT',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ action: 'save_setting', key: 'notify_registration_email', value: notifyRegistrationEmail }),
- })
- if ((await res.json()).success) toast({ title: 'Gespeichert' })
- } catch { toast({ title: 'Fehler', variant: 'destructive' }) }
- }} disabled={smtpLoading}>Speichern
-
-
-
- {/* SMTP Settings */}
-
-
-
- E-Mail / SMTP Konfiguration
-
-
SMTP-Server für den E-Mail-Versand konfigurieren. Empfohlen: TLS auf Port 587. Passwörter werden verschlüsselt in der Datenbank gespeichert.
-
-
-
- {smtpLoading ? : null}
- Speichern
-
-
- Verbindung testen
-
- {smtpStatus === 'connected' && Verbunden }
- {smtpStatus === 'error' && Fehlgeschlagen }
- {smtpStatus === 'saved' && Gespeichert }
-
-
-
Test-E-Mail senden
-
- setSmtpTestEmail(e.target.value)} placeholder="empfaenger@example.com" className="max-w-xs" />
-
-
- Senden
-
-
-
-
-
- {/* Stripe Settings */}
-
-
-
- Stripe / Spenden-Konfiguration
-
-
- Stripe API-Keys für die Spendenseite konfigurieren. Unterstützt Kreditkarte, Twint und weitere Zahlungsmethoden.
- Keys findest du im Stripe Dashboard .
-
-
-
- Webhook-Endpoint: {typeof window !== 'undefined' ? window.location.origin : ''}/api/donate/webhook
-
-
-
- {stripeLoading ? : null}
- Speichern
-
- {stripeStatus === 'saved' && Gespeichert }
- {stripeStatus === 'error' && Fehlgeschlagen }
-
-
-
- {/* Demo Project */}
-
-
-
- Live-Demo auf der Startseite
-
-
- Wähle ein Projekt als Demo-Karte für die Landing Page. Besucher können die Karte sehen und zoomen, aber nichts bearbeiten.
-
-
-
- Demo-Projekt
- setDemoProjectId(e.target.value)}
- className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
- >
- — Keine Demo —
- {allProjects.map(p => (
- {p.title}{p.location ? ` (${p.location})` : ''}
- ))}
-
-
-
{
- setDemoLoading(true)
- setDemoStatus(null)
- try {
- const res = await fetch('/api/admin/settings', {
- method: 'PUT',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ action: 'save_demo_project', demoProjectId }),
- })
- const data = await res.json()
- if (data.success) {
- toast({ title: 'Demo-Projekt gespeichert' })
- setDemoStatus('saved')
- } else throw new Error(data.error)
- } catch (error) {
- toast({ title: 'Fehler', description: error instanceof Error ? error.message : 'Fehler', variant: 'destructive' })
- setDemoStatus('error')
- } finally { setDemoLoading(false) }
- }}
- disabled={demoLoading}
- >
- {demoLoading ? : null}
- Speichern
-
- {demoStatus === 'saved' &&
Gespeichert}
-
- {demoProjectId && (
-
- Vorschau: /demo
-
- )}
-
-
- {/* Symbol-Grösse */}
-
-
-
- Standard Symbol-Grösse
-
-
- Bestimmt die Standard-Grösse neuer Symbole auf der Karte. Kleinere Werte = kleinere Symbole.
-
-
- setDefaultSymbolScale(e.target.value)}
- className="flex-1"
- />
- {defaultSymbolScale}x
-
-
- 0.5x (klein)
-
- 5x (gross)
-
-
- {
- setSymbolScaleLoading(true)
- setSymbolScaleStatus(null)
- try {
- const res = await fetch('/api/admin/settings', {
- method: 'PUT',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ action: 'save_setting', key: 'default_symbol_scale', value: defaultSymbolScale }),
- })
- const data = await res.json()
- if (data.success) setSymbolScaleStatus('saved')
- } catch {} finally { setSymbolScaleLoading(false) }
- }}
- >
- {symbolScaleLoading ? : null}
- Speichern
-
- {symbolScaleStatus === 'saved' && Gespeichert }
-
-
-
- {/* App Info */}
-
-
-
- System-Info
-
-
-
Version 1.0.0
-
Framework Next.js 14.1
-
Datenbank PostgreSQL 16
-
Benutzer {users.length}
-
Mandanten {tenants.length}
-
Symbole {icons.length}
-
-
-
- {/* Quick Actions */}
-
-
-
- Schnellaktionen
-
-
-
setActiveTab('tenants')}>
-
- Mandanten verwalten
-
-
setActiveTab('users')}>
-
- Benutzer anlegen
-
-
-
-
- Zur Krokier-App
-
-
-
-
-
+
)}
@@ -1848,7 +961,7 @@ export default function AdminPage() {
Lageplan unterstützen
- Lageplan ist ein kostenloses Open-Source-Projekt — entwickelt von einem aktiven Feuerwehrmann
+ Lageplan ist ein kostenloses Projekt — entwickelt von einem aktiven Feuerwehrmann
in seiner Freizeit. Ohne Firma, ohne Investoren. Deine Spende hilft, den Betrieb und die
Weiterentwicklung zu finanzieren.
@@ -1901,124 +1014,7 @@ export default function AdminPage() {
{/* ===== SOMA TAB (TENANT_ADMIN) ===== */}
{user?.role === 'TENANT_ADMIN' && (
-
-
-
- SOMA-Checkliste verwalten
-
-
- Definiere die Sofortmassnahmen (SOMA), die bei jedem neuen Einsatz als Checkliste erscheinen.
- Bestehende Einsätze werden nicht verändert.
-
-
- {somaLoading ? (
-
- Laden...
-
- ) : (
- <>
- {/* Template list */}
-
- {somaTemplates.length === 0 ? (
-
- Keine SOMA-Vorlagen definiert. Neue Einsätze starten ohne Checkliste.
-
- ) : somaTemplates.map((tpl, idx) => (
-
-
- {tpl.label}
- #{idx + 1}
- {
- try {
- await fetch('/api/tenant/soma-templates', {
- method: 'PATCH',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ updates: [{ id: tpl.id, isActive: !tpl.isActive }] }),
- })
- fetchSomaTemplates()
- } catch {}
- }}
- >
- {tpl.isActive ? : }
-
- {
- if (!confirm(`"${tpl.label}" wirklich löschen?`)) return
- try {
- await fetch('/api/tenant/soma-templates', {
- method: 'DELETE',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ id: tpl.id }),
- })
- fetchSomaTemplates()
- toast({ title: 'SOMA-Vorlage gelöscht' })
- } catch {}
- }}
- >
-
-
-
- ))}
-
-
- {/* Add new */}
-
-
setNewSomaLabel(e.target.value)}
- onKeyDown={e => {
- if (e.key === 'Enter' && newSomaLabel.trim()) {
- e.preventDefault()
- ;(async () => {
- try {
- await fetch('/api/tenant/soma-templates', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ label: newSomaLabel.trim(), sortOrder: somaTemplates.length }),
- })
- setNewSomaLabel('')
- fetchSomaTemplates()
- toast({ title: 'SOMA-Vorlage hinzugefügt' })
- } catch {}
- })()
- }
- }}
- className="flex-1"
- />
-
{
- try {
- await fetch('/api/tenant/soma-templates', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ label: newSomaLabel.trim(), sortOrder: somaTemplates.length }),
- })
- setNewSomaLabel('')
- fetchSomaTemplates()
- toast({ title: 'SOMA-Vorlage hinzugefügt' })
- } catch {}
- }}
- >
- Hinzufügen
-
-
-
-
- {somaTemplates.filter(t => t.isActive).length} aktiv / {somaTemplates.length} gesamt —
- Nur aktive Vorlagen erscheinen bei neuen Einsätzen.
-
- >
- )}
-
+
)}
diff --git a/src/app/api/icons/route.ts b/src/app/api/icons/route.ts
index bc8fc45..5d0b60b 100644
--- a/src/app/api/icons/route.ts
+++ b/src/app/api/icons/route.ts
@@ -6,20 +6,6 @@ export async function GET() {
try {
const user = await getSession()
- // Build icon filter: global icons (tenantId=null) + tenant-specific icons
- const iconFilter: any = { isActive: true }
- if (user?.tenantId) {
- iconFilter.OR = [
- { tenantId: null },
- { tenantId: user.tenantId },
- ]
- delete iconFilter.isActive
- iconFilter.AND = [{ isActive: true }]
- } else {
- // Server admin or no tenant: show all global icons
- iconFilter.tenantId = null
- }
-
// Filter categories: global (tenantId=null) + tenant-specific
const categoryWhere: any = user?.tenantId
? { OR: [{ tenantId: null }, { tenantId: user.tenantId }] }
@@ -38,35 +24,46 @@ export async function GET() {
},
})
- // Get tenant's hidden icon IDs (legacy) + TenantSymbol overrides
+ // Get tenant's hidden icon IDs (legacy)
let hiddenIconIds: string[] = []
- let deactivatedIconIds = new Set
()
if (user?.tenantId) {
- const [tenant, tenantSymbols] = await Promise.all([
- (prisma as any).tenant.findUnique({
- where: { id: user.tenantId },
- select: { hiddenIconIds: true },
- }),
- (prisma as any).tenantSymbol.findMany({
- where: { tenantId: user.tenantId, isActive: false },
- select: { iconId: true },
- }),
- ])
+ const tenant = await (prisma as any).tenant.findUnique({
+ where: { id: user.tenantId },
+ select: { hiddenIconIds: true },
+ })
hiddenIconIds = tenant?.hiddenIconIds || []
- deactivatedIconIds = new Set(tenantSymbols.map((ts: any) => ts.iconId))
}
const categoriesWithUrls = categories.map((cat: any) => ({
...cat,
icons: cat.icons
- .filter((icon: any) => !hiddenIconIds.includes(icon.id) && !deactivatedIconIds.has(icon.id))
+ .filter((icon: any) => !hiddenIconIds.includes(icon.id))
.map((icon: any) => ({
...icon,
url: `/api/icons/${icon.id}/image`,
})),
}))
- return NextResponse.json({ categories: categoriesWithUrls })
+ // Get tenant's custom symbol collection (with custom names)
+ let mySymbols: any[] = []
+ if (user?.tenantId) {
+ const tenantSymbols = await (prisma as any).tenantSymbol.findMany({
+ where: { tenantId: user.tenantId },
+ include: { icon: { select: { id: true, name: true, mimeType: true, iconType: true } } },
+ orderBy: { sortOrder: 'asc' },
+ })
+ mySymbols = tenantSymbols.map((ts: any) => ({
+ id: ts.icon.id,
+ tenantSymbolId: ts.id,
+ name: ts.customName || ts.icon.name,
+ customName: ts.customName,
+ mimeType: ts.icon.mimeType,
+ iconType: ts.icon.iconType,
+ url: `/api/icons/${ts.icon.id}/image`,
+ }))
+ }
+
+ return NextResponse.json({ categories: categoriesWithUrls, mySymbols })
} catch (error) {
console.error('Error fetching icons:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
diff --git a/src/app/api/tenant/symbols/route.ts b/src/app/api/tenant/symbols/route.ts
index 7cde19b..54a543a 100644
--- a/src/app/api/tenant/symbols/route.ts
+++ b/src/app/api/tenant/symbols/route.ts
@@ -2,82 +2,151 @@ import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession } from '@/lib/auth'
-// GET: List all icons with their tenant-specific active status
+async function getTenantId() {
+ const user = await getSession()
+ if (!user) return { error: 'Nicht autorisiert', status: 401 }
+ if (user.role !== 'TENANT_ADMIN' && user.role !== 'SERVER_ADMIN') {
+ return { error: 'Keine Berechtigung', status: 403 }
+ }
+ if (!user.tenantId) return { error: 'Kein Mandant zugeordnet', status: 400 }
+ return { tenantId: user.tenantId }
+}
+
+// GET: Returns library (all system icons) + tenant's own symbol collection
export async function GET() {
try {
- const user = await getSession()
- if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
- if (user.role !== 'TENANT_ADMIN' && user.role !== 'SERVER_ADMIN') {
- return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
- }
+ const auth = await getTenantId()
+ if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
+ const { tenantId } = auth
- const tenantId = user.tenantId
- if (!tenantId) return NextResponse.json({ error: 'Kein Mandant zugeordnet' }, { status: 400 })
-
- // Get all system icons (active ones)
+ // All system icons grouped by category (the library)
const icons = await (prisma as any).iconAsset.findMany({
where: { isActive: true },
include: { category: { select: { id: true, name: true } } },
orderBy: [{ category: { sortOrder: 'asc' } }, { name: 'asc' }],
})
- // Get tenant-specific overrides
- const overrides = await (prisma as any).tenantSymbol.findMany({
- where: { tenantId },
- })
-
- const overrideMap = new Map(overrides.map((o: any) => [o.iconId, o.isActive]))
-
- // Merge: default is active (true) unless override says otherwise
- const symbols = icons.map((icon: any) => ({
+ const library = icons.map((icon: any) => ({
id: icon.id,
name: icon.name,
- fileKey: icon.fileKey,
mimeType: icon.mimeType,
iconType: icon.iconType,
categoryId: icon.categoryId,
categoryName: icon.category?.name || 'Ohne Kategorie',
- isActive: overrideMap.has(icon.id) ? overrideMap.get(icon.id) : true,
}))
- return NextResponse.json({ symbols })
+ // Tenant's own symbol collection
+ const tenantSymbols = await (prisma as any).tenantSymbol.findMany({
+ where: { tenantId },
+ include: { icon: { select: { id: true, name: true, mimeType: true, iconType: true, category: { select: { name: true } } } } },
+ orderBy: { sortOrder: 'asc' },
+ })
+
+ const mySymbols = tenantSymbols.map((ts: any) => ({
+ id: ts.id,
+ iconId: ts.iconId,
+ name: ts.customName || ts.icon.name,
+ customName: ts.customName,
+ baseName: ts.icon.name,
+ mimeType: ts.icon.mimeType,
+ iconType: ts.icon.iconType,
+ categoryName: ts.icon.category?.name || 'Ohne Kategorie',
+ sortOrder: ts.sortOrder,
+ }))
+
+ return NextResponse.json({ library, mySymbols })
} catch (error) {
console.error('Error fetching tenant symbols:', error)
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
}
}
-// PATCH: Update symbol visibility for the tenant (bulk)
-export async function PATCH(req: NextRequest) {
+// POST: Add a symbol from the library to "my symbols"
+export async function POST(req: NextRequest) {
try {
- const user = await getSession()
- if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
- if (user.role !== 'TENANT_ADMIN' && user.role !== 'SERVER_ADMIN') {
- return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
- }
+ const auth = await getTenantId()
+ if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
+ const { tenantId } = auth
- const tenantId = user.tenantId
- if (!tenantId) return NextResponse.json({ error: 'Kein Mandant zugeordnet' }, { status: 400 })
+ const { iconId, customName } = await req.json()
+ if (!iconId) return NextResponse.json({ error: 'iconId erforderlich' }, { status: 400 })
- const { updates } = await req.json()
- if (!Array.isArray(updates)) {
- return NextResponse.json({ error: 'updates Array erforderlich' }, { status: 400 })
- }
+ // Get max sortOrder for this tenant
+ const maxSort = await (prisma as any).tenantSymbol.aggregate({
+ where: { tenantId },
+ _max: { sortOrder: true },
+ })
- // Upsert each symbol override
- await Promise.all(
- updates.map((u: { iconId: string; isActive: boolean }) =>
- (prisma as any).tenantSymbol.upsert({
- where: { tenantId_iconId: { tenantId, iconId: u.iconId } },
- update: { isActive: u.isActive },
- create: { tenantId, iconId: u.iconId, isActive: u.isActive },
- })
- )
- )
+ const symbol = await (prisma as any).tenantSymbol.create({
+ data: {
+ tenantId,
+ iconId,
+ customName: customName || null,
+ sortOrder: (maxSort._max.sortOrder ?? -1) + 1,
+ },
+ include: { icon: { select: { name: true, mimeType: true, iconType: true, category: { select: { name: true } } } } },
+ })
- return NextResponse.json({ success: true })
+ return NextResponse.json({
+ id: symbol.id,
+ iconId: symbol.iconId,
+ name: symbol.customName || symbol.icon.name,
+ customName: symbol.customName,
+ baseName: symbol.icon.name,
+ mimeType: symbol.icon.mimeType,
+ iconType: symbol.icon.iconType,
+ categoryName: symbol.icon.category?.name || 'Ohne Kategorie',
+ sortOrder: symbol.sortOrder,
+ })
} catch (error) {
- console.error('Error updating tenant symbols:', error)
+ console.error('Error adding tenant symbol:', error)
+ return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
+ }
+}
+
+// PATCH: Rename a symbol or update sortOrder
+export async function PATCH(req: NextRequest) {
+ try {
+ const auth = await getTenantId()
+ if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
+ const { tenantId } = auth
+
+ const { id, customName, sortOrder } = await req.json()
+ if (!id) return NextResponse.json({ error: 'id erforderlich' }, { status: 400 })
+
+ const data: any = {}
+ if (customName !== undefined) data.customName = customName || null
+ if (sortOrder !== undefined) data.sortOrder = sortOrder
+
+ await (prisma as any).tenantSymbol.updateMany({
+ where: { id, tenantId },
+ data,
+ })
+
+ return NextResponse.json({ success: true })
+ } catch (error) {
+ console.error('Error updating tenant symbol:', error)
+ return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
+ }
+}
+
+// DELETE: Remove a symbol from "my symbols"
+export async function DELETE(req: NextRequest) {
+ try {
+ const auth = await getTenantId()
+ if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
+ const { tenantId } = auth
+
+ const { id } = await req.json()
+ if (!id) return NextResponse.json({ error: 'id erforderlich' }, { status: 400 })
+
+ await (prisma as any).tenantSymbol.deleteMany({
+ where: { id, tenantId },
+ })
+
+ return NextResponse.json({ success: true })
+ } catch (error) {
+ console.error('Error deleting tenant symbol:', error)
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
}
}
diff --git a/src/app/app/error.tsx b/src/app/app/error.tsx
new file mode 100644
index 0000000..3da7dfe
--- /dev/null
+++ b/src/app/app/error.tsx
@@ -0,0 +1,35 @@
+'use client'
+
+import { AlertTriangle, RotateCcw, Home } from 'lucide-react'
+import { Button } from '@/components/ui/button'
+import Link from 'next/link'
+
+export default function AppError({
+ error,
+ reset,
+}: {
+ error: Error & { digest?: string }
+ reset: () => void
+}) {
+ return (
+
+
+
Fehler in der Krokier-App
+
+ {error.message || 'Ein unerwarteter Fehler ist aufgetreten. Bitte versuche es erneut.'}
+
+
+
+
+ Erneut versuchen
+
+
+
+
+ Startseite
+
+
+
+
+ )
+}
diff --git a/src/app/app/page.tsx b/src/app/app/page.tsx
index cea321c..6d8320b 100644
--- a/src/app/app/page.tsx
+++ b/src/app/app/page.tsx
@@ -18,68 +18,32 @@ import { useAuth } from '@/components/providers/auth-provider'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { JournalView } from '@/components/journal/journal-view'
-import { jsPDF } from 'jspdf'
import { Lock, Unlock, Eye, AlertTriangle, WifiOff } from 'lucide-react'
-import { getSocket, setSocketRoom } from '@/lib/socket'
import { CustomDragLayer } from '@/components/map/custom-drag-layer'
import { OnboardingTour, resetOnboardingTour } from '@/components/onboarding/onboarding-tour'
-import { addToSyncQueue, flushSyncQueue, getSyncQueue, isOnline as checkOnline } from '@/lib/offline-sync'
+import { useKeyboardShortcuts } from '@/hooks/use-keyboard-shortcuts'
+import { useMapExport } from '@/hooks/use-map-export'
+import { useAutoSave } from '@/hooks/use-auto-save'
+import { useOfflineSync } from '@/hooks/use-offline-sync'
+import { useRealtimeSync } from '@/hooks/use-realtime-sync'
+import type { Project, DrawFeature, Feature, JournalEntry, DrawMode } from '@/types'
+import { useToolStore } from '@/stores/tool-store'
+import { useUIStore } from '@/stores/ui-store'
-export interface Project {
- id: string
- title: string
- location?: string
- description?: string
- einsatzleiter?: string
- journalfuehrer?: string
- mapCenter: { lng: number; lat: number }
- mapZoom: number
- isLocked: boolean
- editingById?: string | null
- editingUserName?: string | null
- editingStartedAt?: string | null
- planImageKey?: string | null
- planBounds?: { north: number; south: number; east: number; west: number } | null
- createdAt: string
- updatedAt: string
-}
-
-export interface DrawFeature {
- id: string
- type: string
- geometry: {
- type: string
- coordinates: number[] | number[][] | number[][][]
- }
- properties: Record
-}
-
-export type DrawMode =
- | 'select'
- | 'point'
- | 'linestring'
- | 'polygon'
- | 'rectangle'
- | 'circle'
- | 'freehand'
- | 'text'
- | 'arrow'
- | 'measure'
- | 'dangerzone'
- | 'eraser'
+export type { DrawMode }
export default function AppPage() {
+ const router = useRouter()
const { toast } = useToast()
const { user, tenant, loading: authLoading, logout } = useAuth()
+ // Zustand Stores
+ const { activeTool: drawMode, setActiveTool: setDrawMode, activeColor: selectedColor, setActiveColor: setSelectedColor, lineWidth: selectedWidth, setLineWidth: setSelectedWidth } = useToolStore()
+ const { sidebarOpen: isSidebarOpen, setSidebarOpen: setIsSidebarOpen, sidebarTab: activeTab, setSidebarTab: setActiveTab } = useUIStore()
+
const [currentProject, setCurrentProject] = useState(null)
const [features, setFeatures] = useState([])
- const [drawMode, setDrawModeRaw] = useState('select')
- const setDrawMode = useCallback((mode: DrawMode) => {
- setDrawModeRaw(mode)
- }, [])
- const [selectedColor, setSelectedColor] = useState('#000000')
- const [selectedWidth, setSelectedWidth] = useState(3)
+
const [isProjectDialogOpen, setIsProjectDialogOpen] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const [isDeleteAllConfirmOpen, setIsDeleteAllConfirmOpen] = useState(false)
@@ -87,25 +51,39 @@ export default function AppPage() {
const [auditLog, setAuditLog] = useState<{ time: string; action: string }[]>([])
const [isAuditOpen, setIsAuditOpen] = useState(false)
- const [isSidebarOpen, setIsSidebarOpen] = useState(false)
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false)
- const [activeTab, setActiveTab] = useState<'map' | 'journal'>('map')
const [lastMapScreenshot, setLastMapScreenshot] = useState('')
const [defaultSymbolScale, setDefaultSymbolScale] = useState(1.5)
// Onboarding tour
const [showTour, setShowTour] = useState(false)
- // Live editing lock state
- const [editingBy, setEditingBy] = useState<{ id: string; name: string; since: string } | null>(null)
- const [isEditingByMe, setIsEditingByMe] = useState(false)
- const [editingLoading, setEditingLoading] = useState(false)
+ // Ref to access the map for export
+ const mapRef = useRef(null)
- // Unique session ID per browser tab (survives re-renders, not page reload)
- const sessionIdRef = useRef('')
- if (!sessionIdRef.current) {
- sessionIdRef.current = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
- }
+ // Undo/Redo history
+ const undoStackRef = useRef([])
+ const redoStackRef = useRef([])
+
+ // Ref to always have latest features (avoids stale closures in callbacks called via refs)
+ const featuresRef = useRef(features)
+ useEffect(() => { featuresRef.current = features }, [features])
+
+ // Ref for undo-draw-point (removes last point during line drawing)
+ const undoDrawPointRef = useRef<(() => boolean) | null>(null)
+
+ // Realtime sync: editing lock, socket.io, throttled broadcast
+ const {
+ editingBy, isEditingByMe, editingLoading,
+ socketRef, broadcastFeatures,
+ handleStartEditing, handleStopEditing,
+ } = useRealtimeSync({
+ currentProject,
+ user: user ? { id: user.id, name: user.name, role: user.role } : null,
+ featuresRef,
+ setFeatures,
+ toast: toast as any,
+ })
// Capture map screenshot when switching to journal tab (coordinate-based rendering)
const handleTabChange = useCallback(async (tab: 'map' | 'journal') => {
@@ -368,67 +346,8 @@ export default function AppPage() {
const [isLineLabelDialogOpen, setIsLineLabelDialogOpen] = useState(false)
const [pendingLineFeature, setPendingLineFeature] = useState(null)
- // Ref to access the map for export
- const mapRef = useRef(null)
-
- // Offline detection
- const [isOffline, setIsOffline] = useState(false)
- const [syncQueueCount, setSyncQueueCount] = useState(0)
-
- useEffect(() => {
- setIsOffline(!checkOnline())
- setSyncQueueCount(getSyncQueue().length)
-
- const goOffline = () => {
- setIsOffline(true)
- toast({ title: 'Offline-Modus', description: 'Änderungen werden lokal gespeichert und beim Reconnect synchronisiert.' })
- }
- const goOnline = async () => {
- setIsOffline(false)
- const queue = getSyncQueue()
- if (queue.length > 0) {
- toast({ title: 'Verbindung wiederhergestellt', description: `${queue.length} Änderung(en) werden synchronisiert...` })
- const result = await flushSyncQueue()
- setSyncQueueCount(getSyncQueue().length)
- if (result.success > 0) {
- toast({ title: 'Synchronisiert', description: `${result.success} Änderung(en) erfolgreich gespeichert.` })
- }
- if (result.failed > 0) {
- toast({ title: 'Sync-Fehler', description: `${result.failed} Änderung(en) konnten nicht gespeichert werden.`, variant: 'destructive' })
- }
- } else {
- toast({ title: 'Wieder online' })
- }
- }
-
- window.addEventListener('offline', goOffline)
- window.addEventListener('online', goOnline)
-
- // Listen for SW sync messages
- if ('serviceWorker' in navigator) {
- navigator.serviceWorker.addEventListener('message', (event) => {
- if (event.data?.type === 'FLUSH_SYNC_QUEUE') {
- flushSyncQueue().then(() => setSyncQueueCount(getSyncQueue().length))
- }
- })
- }
-
- return () => {
- window.removeEventListener('offline', goOffline)
- window.removeEventListener('online', goOnline)
- }
- }, [])
-
- // Undo/Redo history
- const undoStackRef = useRef([])
- const redoStackRef = useRef([])
-
- // Ref to always have latest features (avoids stale closures in callbacks called via refs)
- const featuresRef = useRef(features)
- useEffect(() => { featuresRef.current = features }, [features])
-
- // Ref for undo-draw-point (removes last point during line drawing)
- const undoDrawPointRef = useRef<(() => boolean) | null>(null)
+ // Offline detection + sync queue management
+ const { isOffline, syncQueueCount, setSyncQueueCount } = useOfflineSync({ toast: toast as any })
// Audit trail helper
const addAudit = useCallback((action: string) => {
@@ -447,8 +366,6 @@ export default function AppPage() {
}).catch(() => {})
}, [])
- const router = useRouter()
-
// Redirect to login if not authenticated
useEffect(() => {
if (!authLoading && !user) {
@@ -461,314 +378,16 @@ export default function AppPage() {
const canEdit = roleCanEdit && (isEditingByMe || !editingBy)
const isReadOnly = !!editingBy && !isEditingByMe
- // ─── Editing Lock: Check status + Heartbeat + Polling ─────────
-
- const checkEditingStatus = useCallback(async (projectId: string) => {
- try {
- const res = await fetch(`/api/projects/${projectId}/editing?sessionId=${sessionIdRef.current}`)
- if (!res.ok) return
- const data = await res.json()
- if (data.editing) {
- setEditingBy(data.editingBy)
- setIsEditingByMe(data.isMe)
- } else {
- setEditingBy(null)
- setIsEditingByMe(false)
- }
- } catch (e) {
- console.warn('[Editing] Status check failed:', e)
- }
- }, [])
-
- // Check editing status when project changes
- useEffect(() => {
- if (!currentProject?.id) {
- setEditingBy(null)
- setIsEditingByMe(false)
- return
- }
- checkEditingStatus(currentProject.id)
- }, [currentProject?.id, checkEditingStatus])
-
- // Heartbeat: keep lock alive every 30s while I'm editing
- useEffect(() => {
- if (!currentProject?.id || !isEditingByMe) return
- const interval = setInterval(async () => {
- try {
- await fetch(`/api/projects/${currentProject.id}/editing`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ action: 'heartbeat', sessionId: sessionIdRef.current }),
- })
- } catch (e) {
- console.warn('[Heartbeat] Failed:', e)
- }
- }, 30000)
- return () => clearInterval(interval)
- }, [currentProject?.id, isEditingByMe])
-
- // Socket.io: real-time sync for features, editing status, journal
- const socketRef = useRef(null)
- const prevProjectIdRef = useRef(null)
-
- // Throttled socket broadcast for near-real-time sync
- const lastEmitRef = useRef(0)
- const emitTimerRef = useRef | null>(null)
- const currentProjectRef = useRef(currentProject)
- useEffect(() => { currentProjectRef.current = currentProject }, [currentProject])
-
- const broadcastFeatures = useCallback((feats: DrawFeature[]) => {
- const proj = currentProjectRef.current
- if (!socketRef.current || !proj?.id || !isEditingByMeRef.current) return
- const now = Date.now()
- const emit = () => {
- socketRef.current?.emit('features-updated', {
- projectId: proj!.id,
- features: feats,
- })
- lastEmitRef.current = Date.now()
- }
- // Throttle: emit at most every 800ms for snappier sync
- if (now - lastEmitRef.current > 800) {
- emit()
- } else {
- if (emitTimerRef.current) clearTimeout(emitTimerRef.current)
- emitTimerRef.current = setTimeout(emit, 800 - (now - lastEmitRef.current))
- }
- }, [])
- const isEditingByMeRef = useRef(false)
-
- // Keep ref in sync with state
- useEffect(() => {
- isEditingByMeRef.current = isEditingByMe
- }, [isEditingByMe])
-
- useEffect(() => {
- if (!currentProject?.id) return
-
- const socket = getSocket()
- socketRef.current = socket
-
- // Leave old room, join new room
- if (prevProjectIdRef.current && prevProjectIdRef.current !== currentProject.id) {
- socket.emit('leave-project', prevProjectIdRef.current)
- }
- socket.emit('join-project', currentProject.id)
- setSocketRoom(currentProject.id)
- prevProjectIdRef.current = currentProject.id
-
- // Listen for features changes from other clients (only apply if NOT the editor)
- const onFeaturesChanged = (data: { features: any[] }) => {
- // Skip if I'm the one editing — my local state is the source of truth
- if (isEditingByMeRef.current) {
- console.log('[Socket.io] Ignoring features-changed (I am the editor)')
- return
- }
- if (data.features && Array.isArray(data.features)) {
- console.log('[Socket.io] Features updated from another client')
- setFeatures(data.features)
- }
- }
-
- // Listen for editing status changes from other clients
- const onEditingStatus = (data: { editing: boolean; editingBy: any; sessionId: string }) => {
- if (data.sessionId === sessionIdRef.current) return // ignore own events
- if (data.editing && data.editingBy) {
- setEditingBy(data.editingBy)
- setIsEditingByMe(false)
- } else {
- setEditingBy(null)
- setIsEditingByMe(false)
- }
- }
-
- // Listen for journal changes — trigger a re-fetch in JournalView
- const onJournalChanged = () => {
- console.log('[Socket.io] Journal updated from another client')
- window.dispatchEvent(new CustomEvent('journal-refresh'))
- }
-
- socket.on('features-changed', onFeaturesChanged)
- socket.on('editing-status', onEditingStatus)
- socket.on('journal-changed', onJournalChanged)
-
- return () => {
- socket.off('features-changed', onFeaturesChanged)
- socket.off('editing-status', onEditingStatus)
- socket.off('journal-changed', onJournalChanged)
- }
- }, [currentProject?.id])
-
- // Fallback: check editing status on initial load and every 30s
- useEffect(() => {
- if (!currentProject?.id) return
- checkEditingStatus(currentProject.id)
- const interval = setInterval(() => checkEditingStatus(currentProject.id), 30000)
- return () => clearInterval(interval)
- }, [currentProject?.id, checkEditingStatus])
-
- // Release lock on unmount / page close
- useEffect(() => {
- const release = () => {
- if (currentProject?.id && isEditingByMe) {
- const blob = new Blob([JSON.stringify({ action: 'stop', sessionId: sessionIdRef.current })], { type: 'application/json' })
- navigator.sendBeacon(`/api/projects/${currentProject.id}/editing`, blob)
- }
- }
- window.addEventListener('beforeunload', release)
- return () => {
- window.removeEventListener('beforeunload', release)
- release()
- }
- }, [currentProject?.id, isEditingByMe])
-
- const handleStartEditing = useCallback(async () => {
- if (!currentProject?.id) return
- setEditingLoading(true)
- try {
- const res = await fetch(`/api/projects/${currentProject.id}/editing`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ action: 'start', sessionId: sessionIdRef.current }),
- })
- if (!res.ok) {
- const data = await res.json()
- toast({ title: 'Gesperrt', description: data.error || 'Bearbeitung nicht möglich', variant: 'destructive' })
- return
- }
- setIsEditingByMe(true)
- const editingInfo = { id: user!.id, name: user!.name, since: new Date().toISOString() }
- setEditingBy(editingInfo)
- // Notify other clients
- socketRef.current?.emit('editing-changed', {
- projectId: currentProject.id,
- editing: true,
- editingBy: editingInfo,
- sessionId: sessionIdRef.current,
- })
- toast({ title: 'Bearbeitung gestartet', description: 'Sie können jetzt zeichnen und Einträge erstellen.' })
- } catch (e) {
- toast({ title: 'Fehler', description: 'Konnte Bearbeitung nicht starten.', variant: 'destructive' })
- } finally {
- setEditingLoading(false)
- }
- }, [currentProject?.id, user, toast])
-
- const handleStopEditing = useCallback(async () => {
- if (!currentProject?.id) return
- setEditingLoading(true)
- try {
- // Save features before releasing lock
- const currentFeatures = featuresRef.current
- await fetch(`/api/projects/${currentProject.id}/features`, {
- method: 'PUT',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ features: currentFeatures }),
- })
- // Release lock
- await fetch(`/api/projects/${currentProject.id}/editing`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ action: 'stop', sessionId: sessionIdRef.current }),
- })
- setIsEditingByMe(false)
- setEditingBy(null)
- // Notify other clients: editing stopped + send final features
- socketRef.current?.emit('editing-changed', {
- projectId: currentProject.id,
- editing: false,
- editingBy: null,
- sessionId: sessionIdRef.current,
- })
- socketRef.current?.emit('features-updated', {
- projectId: currentProject.id,
- features: currentFeatures,
- })
- toast({ title: 'Bearbeitung beendet', description: 'Änderungen gespeichert. Andere können jetzt bearbeiten.' })
- } catch (e) {
- toast({ title: 'Fehler', description: 'Konnte Bearbeitung nicht beenden.', variant: 'destructive' })
- } finally {
- setEditingLoading(false)
- }
- }, [currentProject?.id, toast])
-
- // Persist features to localStorage on change (including empty array to reflect deletions)
- useEffect(() => {
- localStorage.setItem('lageplan-features', JSON.stringify(features))
- }, [features])
-
- // Auto-save to API — debounced 2s after every feature change + fallback interval
- const saveTimerRef = useRef | null>(null)
- const saveFeaturesToApi = useCallback(async () => {
- if (!currentProject?.id) return
- const url = `/api/projects/${currentProject.id}/features`
- const mapInstance = mapRef.current
- const body: any = { features: featuresRef.current }
- if (mapInstance) {
- const c = mapInstance.getCenter()
- body.mapCenter = { lng: c.lng, lat: c.lat }
- body.mapZoom = mapInstance.getZoom()
- }
-
- // If offline, queue the save for later sync
- if (!navigator.onLine) {
- addToSyncQueue(url, 'PUT', body)
- setSyncQueueCount(getSyncQueue().length)
- console.log('[Auto-Save] Offline — in Sync-Queue gespeichert')
- return
- }
-
- try {
- const res = await fetch(url, {
- method: 'PUT',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(body),
- })
- if (res.ok) {
- console.log('[Auto-Save] Features gespeichert')
- socketRef.current?.emit('features-updated', {
- projectId: currentProject.id,
- features: featuresRef.current,
- })
- } else if (res.status === 404) {
- console.warn('[Auto-Save] Projekt nicht in DB')
- }
- } catch (e) {
- // Network error — queue for later
- addToSyncQueue(url, 'PUT', body)
- setSyncQueueCount(getSyncQueue().length)
- console.warn('[Auto-Save] Netzwerkfehler — in Sync-Queue:', e)
- }
- }, [currentProject])
-
- // Debounced save on every feature change (2s delay)
- useEffect(() => {
- if (!currentProject || !isEditingByMe) return
- if (saveTimerRef.current) clearTimeout(saveTimerRef.current)
- saveTimerRef.current = setTimeout(() => saveFeaturesToApi(), 2000)
- return () => { if (saveTimerRef.current) clearTimeout(saveTimerRef.current) }
- }, [features, currentProject, isEditingByMe, saveFeaturesToApi])
-
- // Also save on page unload / tab switch
- useEffect(() => {
- const handleBeforeUnload = () => {
- if (currentProject?.id && featuresRef.current.length > 0) {
- const payload = JSON.stringify({ features: featuresRef.current })
- navigator.sendBeacon(`/api/projects/${currentProject.id}/features`, new Blob([payload], { type: 'application/json' }))
- }
- }
- const handleVisibilityChange = () => {
- if (document.visibilityState === 'hidden' && currentProject?.id && isEditingByMe) {
- saveFeaturesToApi()
- }
- }
- window.addEventListener('beforeunload', handleBeforeUnload)
- document.addEventListener('visibilitychange', handleVisibilityChange)
- return () => {
- window.removeEventListener('beforeunload', handleBeforeUnload)
- document.removeEventListener('visibilitychange', handleVisibilityChange)
- }
- }, [currentProject, isEditingByMe, saveFeaturesToApi])
+ // Auto-save: localStorage persistence + debounced API save + beacon on unload
+ useAutoSave({
+ currentProject,
+ features,
+ featuresRef,
+ mapRef,
+ socketRef,
+ isEditingByMe,
+ setSyncQueueCount,
+ })
// Fullscreen toggle
const toggleFullscreen = useCallback(() => {
@@ -1066,57 +685,15 @@ export default function AppPage() {
// Keyboard shortcuts for tools
const [isShortcutHelpOpen, setIsShortcutHelpOpen] = useState(false)
- useEffect(() => {
- const handleKeyDown = (e: KeyboardEvent) => {
- // Ignore when typing in inputs/textareas
- const tag = (e.target as HTMLElement)?.tagName
- if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || (e.target as HTMLElement)?.isContentEditable) return
-
- // ? or F1 → help
- if (e.key === '?' || e.key === 'F1') { e.preventDefault(); setIsShortcutHelpOpen(true); return }
-
- // DEL / Backspace → delete selected feature(s)
- if (e.key === 'Delete' || e.key === 'Backspace') {
- e.preventDefault()
- // Remove all selected features
- const current = featuresRef.current
- const selected = current.filter(f => f.properties?._selected)
- if (selected.length > 0) {
- handleFeaturesChange(current.filter(f => !f.properties?._selected))
- }
- return
- }
-
- // Ctrl/Cmd shortcuts
- if (e.ctrlKey || e.metaKey) {
- if (e.key === 'z' && e.shiftKey) { e.preventDefault(); handleRedo(); return }
- if (e.key === 'z') { e.preventDefault(); handleUndo(); return }
- if (e.key === 'y') { e.preventDefault(); handleRedo(); return }
- if (e.key === 's') { e.preventDefault(); handleSaveProject(); return }
- return
- }
-
- // Tool shortcuts (single key, no modifier)
- const shortcuts: Record = {
- 'v': 'select', 's': 'select',
- 'p': 'point',
- 'l': 'linestring',
- 'g': 'polygon',
- 'r': 'rectangle',
- 'c': 'circle',
- 'f': 'freehand',
- 'a': 'arrow',
- 't': 'text',
- 'e': 'eraser',
- 'm': 'measure',
- 'd': 'dangerzone',
- }
- const mode = shortcuts[e.key.toLowerCase()]
- if (mode) { e.preventDefault(); setDrawMode(mode); return }
- }
- window.addEventListener('keydown', handleKeyDown)
- return () => window.removeEventListener('keydown', handleKeyDown)
- }, [handleUndo, handleRedo, handleSaveProject, setDrawMode, handleFeaturesChange])
+ useKeyboardShortcuts({
+ featuresRef,
+ onUndo: handleUndo,
+ onRedo: handleRedo,
+ onSave: handleSaveProject,
+ onDelete: handleFeaturesChange,
+ onToolChange: setDrawMode,
+ onHelpOpen: useCallback(() => setIsShortcutHelpOpen(true), []),
+ })
const handlePlanUpload = useCallback(() => {
if (!currentProject) return
@@ -1181,311 +758,14 @@ export default function AppPage() {
setIsDeleteAllConfirmOpen(false)
}, [toast, addAudit])
- const handleExport = useCallback(async (format: 'png' | 'pdf') => {
- const mapInstance = mapRef.current
- if (!mapInstance) {
- toast({ title: 'Fehler', description: 'Karte nicht bereit.', variant: 'destructive' })
- return
- }
-
- try {
- // 1. Get the MapLibre canvas (tiles + vector drawings)
- const mapCanvas = mapInstance.getCanvas() as HTMLCanvasElement
- const w = mapCanvas.width
- const h = mapCanvas.height
-
- // 2. Create composite canvas
- const exportCanvas = document.createElement('canvas')
- exportCanvas.width = w
- exportCanvas.height = h
- const ctx = exportCanvas.getContext('2d')!
- ctx.drawImage(mapCanvas, 0, 0)
-
- // 3. Draw symbols manually at correct size/rotation
- const currentFeatures = featuresRef.current
- // Derive actual pixel ratio from canvas vs container (more reliable than window.devicePixelRatio)
- const container = mapInstance.getContainer()
- const dpr = mapCanvas.width / container.offsetWidth
- const zoom = mapInstance.getZoom()
- // Symbol sizing: match the map rendering logic exactly
- // In map-view.tsx: size = baseSize * scale * Math.pow(2, currentZoom - placementZoom)
- const currentZoom = zoom
-
- // Helper: load image as promise
- const loadImage = (src: string): Promise => new Promise((resolve, reject) => {
- const img = new Image()
- img.crossOrigin = 'anonymous'
- img.onload = () => resolve(img)
- img.onerror = reject
- img.src = src
- })
-
- // Draw symbol features
- for (const f of currentFeatures.filter(f => f.type === 'symbol')) {
- if (f.geometry.type !== 'Point') continue
- const coords = f.geometry.coordinates as [number, number]
- const pixel = mapInstance.project(coords)
- const px = pixel.x * dpr
- const py = pixel.y * dpr
-
- const scale = (f.properties.scale as number) || 1
- const rotation = (f.properties.rotation as number) || 0
- const baseSize = 32
- const placementZoom = (f.properties.placementZoom as number) || 17
- const zoomFactor = Math.pow(2, currentZoom - placementZoom)
- const size = Math.max(8, Math.min(400, baseSize * scale * zoomFactor)) * dpr
-
- // Determine image source
- const iconId = f.properties.iconId as string
- const imageUrl = f.properties.imageUrl as string
- let imgSrc = imageUrl || ''
- if (!imgSrc && iconId) {
- const { getSymbolById, getSymbolDataUri } = await import('@/lib/fw-symbols')
- const sym = getSymbolById(iconId)
- if (sym) imgSrc = getSymbolDataUri(sym)
- }
-
- if (imgSrc) {
- try {
- const img = await loadImage(imgSrc)
- // Replicate CSS background-size: contain (preserve aspect ratio)
- const imgAspect = img.naturalWidth / img.naturalHeight
- let drawW = size
- let drawH = size
- if (imgAspect > 1) {
- drawH = size / imgAspect
- } else {
- drawW = size * imgAspect
- }
- ctx.save()
- ctx.translate(px, py)
- ctx.rotate((rotation * Math.PI) / 180)
- ctx.drawImage(img, -drawW / 2, -drawH / 2, drawW, drawH)
- ctx.restore()
- } catch (e) {
- console.warn('[Export] Failed to load symbol image:', iconId, e)
- }
- }
- }
-
- // Draw arrowheads for arrow features
- for (const f of currentFeatures.filter(f => f.type === 'arrow')) {
- if (f.geometry.type !== 'LineString') continue
- const lineCoords = f.geometry.coordinates as number[][]
- if (lineCoords.length < 2) continue
- const p1 = lineCoords[lineCoords.length - 2]
- const p2 = lineCoords[lineCoords.length - 1]
- const px1 = mapInstance.project(p1 as [number, number])
- const px2 = mapInstance.project(p2 as [number, number])
- const angle = Math.atan2(px2.y - px1.y, px2.x - px1.x)
- const color = (f.properties.color as string) || '#000000'
- const arrowSize = 14 * dpr
-
- ctx.save()
- ctx.translate(px2.x * dpr, px2.y * dpr)
- ctx.rotate(angle + Math.PI / 2)
- ctx.beginPath()
- ctx.moveTo(0, -arrowSize)
- ctx.lineTo(-arrowSize * 0.7, arrowSize * 0.3)
- ctx.lineTo(arrowSize * 0.7, arrowSize * 0.3)
- ctx.closePath()
- ctx.fillStyle = color
- ctx.fill()
- ctx.restore()
- }
-
- // Draw line/polygon label markers at midpoints
- for (const f of currentFeatures.filter(f => f.properties.label && (f.geometry.type === 'LineString' || f.geometry.type === 'Polygon'))) {
- const label = f.properties.label as string
- let midpoint: [number, number]
-
- if (f.geometry.type === 'LineString') {
- const coords = f.geometry.coordinates as number[][]
- const midIdx = Math.floor(coords.length / 2)
- if (coords.length === 2) {
- midpoint = [(coords[0][0] + coords[1][0]) / 2, (coords[0][1] + coords[1][1]) / 2]
- } else {
- midpoint = coords[midIdx] as [number, number]
- }
- } else {
- // Polygon: centroid of first ring
- const ring = (f.geometry.coordinates as number[][][])[0]
- const len = ring.length - 1
- let cx = 0, cy = 0
- for (let i = 0; i < len; i++) { cx += ring[i][0]; cy += ring[i][1] }
- midpoint = [cx / len, cy / len]
- }
-
- const pixel = mapInstance.project(midpoint)
- const px = pixel.x * dpr
- const py = pixel.y * dpr
- const fontSize = 13 * dpr
- const isDanger = f.type === 'dangerzone'
- const bgColor = isDanger ? 'rgba(220,38,38,0.85)' : 'rgba(0,0,0,0.75)'
- const borderColor = isDanger ? '#dc2626' : 'rgba(255,255,255,0.5)'
-
- ctx.save()
- ctx.font = `bold ${fontSize}px system-ui, sans-serif`
- ctx.textAlign = 'center'
- ctx.textBaseline = 'middle'
- const metrics = ctx.measureText(label)
- const padX = 7 * dpr
- const padY = 3 * dpr
- const boxW = metrics.width + padX * 2
- const boxH = fontSize + padY * 2
- const radius = 4 * dpr
-
- // Background pill
- ctx.fillStyle = bgColor
- ctx.beginPath()
- ctx.roundRect(px - boxW / 2, py - boxH / 2, boxW, boxH, radius)
- ctx.fill()
-
- // Border
- ctx.strokeStyle = borderColor
- ctx.lineWidth = 1.5 * dpr
- ctx.beginPath()
- ctx.roundRect(px - boxW / 2, py - boxH / 2, boxW, boxH, radius)
- ctx.stroke()
-
- // Text
- ctx.fillStyle = '#ffffff'
- ctx.fillText(label, px, py)
- ctx.restore()
- }
-
- // Draw text features
- for (const f of currentFeatures.filter(f => f.type === 'text')) {
- if (f.geometry.type !== 'Point') continue
- const coords = f.geometry.coordinates as [number, number]
- const pixel = mapInstance.project(coords)
- const px = pixel.x * dpr
- const py = pixel.y * dpr
-
- const text = (f.properties.text as string) || ''
- const fontSize = ((f.properties.fontSize as number) || 14) * dpr
- const color = (f.properties.color as string) || '#000000'
-
- ctx.save()
- ctx.font = `bold ${fontSize}px system-ui, sans-serif`
- ctx.textAlign = 'center'
- ctx.textBaseline = 'middle'
- // White outline
- ctx.strokeStyle = '#ffffff'
- ctx.lineWidth = 3 * dpr
- ctx.lineJoin = 'round'
- ctx.strokeText(text, px, py)
- // Fill
- ctx.fillStyle = color
- ctx.fillText(text, px, py)
- ctx.restore()
- }
-
- const title = currentProject?.title || 'Lageplan'
- const safeName = title.replace(/[^a-z0-9äöüÄÖÜß]/gi, '_')
-
- if (format === 'png') {
- const link = document.createElement('a')
- link.download = `${safeName}.png`
- link.href = exportCanvas.toDataURL('image/png')
- link.click()
- addAudit(`Export: ${safeName}.png`)
- toast({ title: 'Exportiert', description: `${safeName}.png wurde heruntergeladen.` })
- } else {
- // PDF Export — rapport-style clean layout
- const imgData = exportCanvas.toDataURL('image/png')
- const now = new Date()
- const dateStr = now.toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit', year: 'numeric' })
- const timeStr = now.toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' })
- const locationStr = currentProject?.location || ''
- const einsatzNr = (currentProject as any)?.einsatzNr || ''
- const tenantLabel = tenant?.name || ''
-
- // A4 landscape (mm)
- const pdf = new jsPDF('l', 'mm', 'a4')
- const pageW = pdf.internal.pageSize.getWidth() // 297
- const pageH = pdf.internal.pageSize.getHeight() // 210
- const m = 10 // margin
-
- // ── Header section ──
- const headerY = m
- pdf.setFontSize(18)
- pdf.setFont('helvetica', 'bold')
- pdf.setTextColor(26, 26, 26)
- pdf.text('Einsatz-Lageplan', m, headerY + 6)
-
- pdf.setFontSize(9)
- pdf.setFont('helvetica', 'normal')
- pdf.setTextColor(107, 114, 128) // gray-500
- pdf.text(`${tenantLabel}${tenantLabel ? ' · ' : ''}${title}`, m, headerY + 12)
-
- // Right side: Einsatz-Nr + date
- pdf.setFontSize(14)
- pdf.setFont('helvetica', 'bold')
- pdf.setTextColor(185, 28, 28) // red-700
- if (einsatzNr) {
- const nrW = pdf.getTextWidth(einsatzNr)
- pdf.text(einsatzNr, pageW - m - nrW, headerY + 6)
- }
- pdf.setFontSize(9)
- pdf.setFont('helvetica', 'normal')
- pdf.setTextColor(107, 114, 128)
- const dateLabel = `${dateStr} · ${timeStr}`
- const dlW = pdf.getTextWidth(dateLabel)
- pdf.text(dateLabel, pageW - m - dlW, headerY + 12)
-
- // Divider line + red accent
- const divY = headerY + 15
- pdf.setDrawColor(26, 26, 26)
- pdf.setLineWidth(0.8)
- pdf.line(m, divY, pageW - m, divY)
- pdf.setFillColor(185, 28, 28)
- pdf.rect(m, divY, (pageW - 2 * m) * 0.3, 1, 'F')
-
- // ── Map image ──
- const mapTop = divY + 3
- const mapBottom = pageH - m - 12 // leave space for footer
- const mapAreaW = pageW - 2 * m
- const mapAreaH = mapBottom - mapTop
-
- // Fit map image into area while preserving aspect ratio
- const imgAspect = w / h
- const areaAspect = mapAreaW / mapAreaH
- let drawW = mapAreaW
- let drawH = mapAreaH
- if (imgAspect > areaAspect) {
- drawH = mapAreaW / imgAspect
- } else {
- drawW = mapAreaH * imgAspect
- }
- const mapX = m + (mapAreaW - drawW) / 2
- const mapY = mapTop + (mapAreaH - drawH) / 2
-
- // Light border around map
- pdf.setDrawColor(229, 231, 235)
- pdf.setLineWidth(0.3)
- pdf.rect(mapX, mapY, drawW, drawH)
- pdf.addImage(imgData, 'PNG', mapX, mapY, drawW, drawH)
-
- // ── Footer ──
- const footerY = pageH - m - 4
- pdf.setFontSize(7)
- pdf.setFont('helvetica', 'normal')
- pdf.setTextColor(156, 163, 175) // gray-400
- pdf.text(`Erstellt: ${dateStr} ${timeStr}${locationStr ? ' · Standort: ' + locationStr : ''} · Projekt: ${title}`, m, footerY)
- const footerR = 'app.lageplan.ch'
- const frW = pdf.getTextWidth(footerR)
- pdf.text(footerR, pageW - m - frW, footerY)
-
- pdf.save(`${safeName}.pdf`)
- addAudit(`Export: ${safeName}.pdf`)
- toast({ title: 'Exportiert', description: `${safeName}.pdf wurde heruntergeladen.` })
- }
- } catch (error) {
- console.error('Export error:', error)
- toast({ title: 'Fehler', description: 'Export fehlgeschlagen.', variant: 'destructive' })
- }
- }, [currentProject, toast])
+ const { handleExport } = useMapExport({
+ mapRef,
+ featuresRef,
+ currentProject,
+ tenant: tenant ? { id: tenant.id, name: tenant.name } : null,
+ addAudit,
+ toast: toast as any,
+ })
// Show loading state while checking auth
if (authLoading || !user) {
@@ -1603,7 +883,7 @@ export default function AppPage() {
{/* Map view — always mounted, hidden via CSS to preserve state */}
- {/* Journal view — always mounted, hidden when map tab is active to preserve state */}
-
+ {/* Journal view — always mounted, hidden via CSS */}
+
([])
+ const [newGlobalWord, setNewGlobalWord] = useState('')
+ const [dictLoading, setDictLoading] = useState(false)
+
+ const fetchGlobalDict = async () => {
+ try {
+ const data = await apiFetch<{ words: DictWord[] }>('/api/dictionary?scope=GLOBAL', { silent: true })
+ if (data?.words) setGlobalDictWords(data.words)
+ } catch {}
+ }
+
+ useEffect(() => {
+ fetchGlobalDict()
+ }, [])
+
+ const handleAdd = async () => {
+ if (!newGlobalWord.trim()) return
+ setDictLoading(true)
+ try {
+ await apiFetch('/api/dictionary', {
+ method: 'POST',
+ body: JSON.stringify({ word: newGlobalWord.trim(), scope: 'GLOBAL' }),
+ })
+ setNewGlobalWord('')
+ fetchGlobalDict()
+ toast({ title: 'Begriff hinzugefügt' })
+ } catch (err) {
+ toast({ title: 'Fehler', description: err instanceof ApiError ? err.message : 'Fehler', variant: 'destructive' })
+ } finally { setDictLoading(false) }
+ }
+
+ return (
+
+
+
+ Globales Wörterbuch
+
+
+ Globale Begriffe, die allen Mandanten als Journal-Vorschläge zur Verfügung stehen.
+ Mandanten können zusätzlich eigene Begriffe über ihre Wörterliste hinzufügen.
+
+
+ {/* Add new word */}
+
+
setNewGlobalWord(e.target.value)}
+ onKeyDown={async (e) => {
+ if (e.key === 'Enter' && newGlobalWord.trim()) handleAdd()
+ }}
+ className="flex-1"
+ disabled={dictLoading}
+ />
+
+
+ Hinzufügen
+
+
+
+ {/* List of global words */}
+ {globalDictWords.length === 0 ? (
+
+ Noch keine globalen Begriffe hinterlegt.
+
+ ) : (
+
+ {globalDictWords.map((w) => (
+
+ {w.word}
+ {
+ try {
+ await apiFetch(`/api/dictionary/${w.id}`, { method: 'DELETE' })
+ fetchGlobalDict()
+ toast({ title: 'Entfernt' })
+ } catch { toast({ title: 'Fehler', variant: 'destructive' }) }
+ }}
+ className="ml-1 hover:text-red-500 transition-colors"
+ >
+
+
+
+ ))}
+
+ )}
+
+
+ {globalDictWords.length} globale(r) Begriff(e). Diese erscheinen bei allen Mandanten als Vorschläge im Journal.
+
+
+ )
+}
diff --git a/src/components/admin/settings-tab.tsx b/src/components/admin/settings-tab.tsx
new file mode 100644
index 0000000..e28139a
--- /dev/null
+++ b/src/components/admin/settings-tab.tsx
@@ -0,0 +1,450 @@
+'use client'
+
+import { useState, useEffect } from 'react'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { useToast } from '@/components/ui/use-toast'
+import Link from 'next/link'
+import {
+ Mail, Send, CheckCircle, Ban, CreditCard, Map, MapPin, Settings,
+ Shield, UserPlus, ArrowLeft, Loader2,
+} from 'lucide-react'
+
+interface SettingsTabProps {
+ usersCount: number
+ tenantsCount: number
+ iconsCount: number
+ onNavigateTab: (tab: string) => void
+}
+
+export function SettingsTab({ usersCount, tenantsCount, iconsCount, onNavigateTab }: SettingsTabProps) {
+ const { toast } = useToast()
+
+ // SMTP Settings
+ const [smtpHost, setSmtpHost] = useState('')
+ const [smtpPort, setSmtpPort] = useState('587')
+ const [smtpSecure, setSmtpSecure] = useState(false)
+ const [smtpUser, setSmtpUser] = useState('')
+ const [smtpPass, setSmtpPass] = useState('')
+ const [smtpFromName, setSmtpFromName] = useState('Lageplan')
+ const [smtpFromEmail, setSmtpFromEmail] = useState('')
+ const [smtpTestEmail, setSmtpTestEmail] = useState('')
+ const [smtpLoading, setSmtpLoading] = useState(false)
+ const [smtpStatus, setSmtpStatus] = useState(null)
+ const [contactEmail, setContactEmail] = useState('app@lageplan.ch')
+ const [notifyRegistrationEmail, setNotifyRegistrationEmail] = useState('')
+
+ // Stripe Settings
+ const [stripePublicKey, setStripePublicKey] = useState('')
+ const [stripeSecretKey, setStripeSecretKey] = useState('')
+ const [stripeWebhookSecret, setStripeWebhookSecret] = useState('')
+ const [stripeLoading, setStripeLoading] = useState(false)
+ const [stripeStatus, setStripeStatus] = useState(null)
+
+ // Demo Project
+ const [demoProjectId, setDemoProjectId] = useState('')
+ const [allProjects, setAllProjects] = useState<{ id: string; title: string; location?: string }[]>([])
+ const [demoLoading, setDemoLoading] = useState(false)
+ const [demoStatus, setDemoStatus] = useState(null)
+
+ // Default Symbol Scale
+ const [defaultSymbolScale, setDefaultSymbolScale] = useState('1.5')
+ const [symbolScaleLoading, setSymbolScaleLoading] = useState(false)
+ const [symbolScaleStatus, setSymbolScaleStatus] = useState(null)
+
+ // Load settings on mount
+ useEffect(() => {
+ fetch('/api/admin/settings').then(r => r.json()).then(data => {
+ if (data.smtp) {
+ setSmtpHost(data.smtp.host || '')
+ setSmtpPort(data.smtp.port?.toString() || '587')
+ setSmtpSecure(data.smtp.secure || false)
+ setSmtpUser(data.smtp.user || '')
+ setSmtpFromName(data.smtp.fromName || 'Lageplan')
+ setSmtpFromEmail(data.smtp.fromEmail || '')
+ }
+ if (data.stripe) {
+ setStripePublicKey(data.stripe.publicKey || '')
+ setStripeSecretKey(data.stripe.secretKey ? '••••••••' : '')
+ setStripeWebhookSecret(data.stripe.webhookSecret ? '••••••••' : '')
+ }
+ if (data.contactEmail) setContactEmail(data.contactEmail)
+ if (data.notifyRegistrationEmail) setNotifyRegistrationEmail(data.notifyRegistrationEmail)
+ if (data.demoProjectId) setDemoProjectId(data.demoProjectId)
+ if (data.defaultSymbolScale) setDefaultSymbolScale(data.defaultSymbolScale.toString())
+ }).catch(() => {})
+
+ // Load projects for demo selector
+ fetch('/api/projects').then(r => r.json()).then(data => {
+ if (data.projects) setAllProjects(data.projects)
+ }).catch(() => {})
+ }, [])
+
+ const handleSmtpSave = async () => {
+ setSmtpLoading(true)
+ setSmtpStatus(null)
+ try {
+ const res = await fetch('/api/admin/settings', {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ action: 'save_smtp',
+ smtp: { host: smtpHost, port: parseInt(smtpPort), secure: smtpSecure, user: smtpUser, pass: smtpPass, fromName: smtpFromName, fromEmail: smtpFromEmail },
+ }),
+ })
+ const data = await res.json()
+ if (data.success) {
+ toast({ title: 'SMTP gespeichert' })
+ setSmtpStatus('saved')
+ } else throw new Error(data.error)
+ } catch (error) {
+ toast({ title: 'Fehler', description: error instanceof Error ? error.message : 'Fehler', variant: 'destructive' })
+ } finally { setSmtpLoading(false) }
+ }
+
+ const handleSmtpTest = async () => {
+ setSmtpLoading(true)
+ setSmtpStatus(null)
+ try {
+ const res = await fetch('/api/admin/settings', {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ action: 'test_smtp' }),
+ })
+ const data = await res.json()
+ if (data.success) {
+ setSmtpStatus('connected')
+ toast({ title: 'SMTP-Verbindung erfolgreich' })
+ } else {
+ setSmtpStatus('error')
+ toast({ title: 'Verbindung fehlgeschlagen', description: data.error, variant: 'destructive' })
+ }
+ } catch (error) {
+ setSmtpStatus('error')
+ toast({ title: 'Fehler', variant: 'destructive' })
+ } finally { setSmtpLoading(false) }
+ }
+
+ const handleSmtpSendTest = async () => {
+ if (!smtpTestEmail) return
+ setSmtpLoading(true)
+ try {
+ const res = await fetch('/api/admin/settings', {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ action: 'send_test_email', testEmail: smtpTestEmail }),
+ })
+ const data = await res.json()
+ if (data.success) toast({ title: data.message })
+ else toast({ title: 'Senden fehlgeschlagen', description: data.error, variant: 'destructive' })
+ } catch { toast({ title: 'Fehler', variant: 'destructive' }) }
+ finally { setSmtpLoading(false) }
+ }
+
+ const handleContactEmailSave = async () => {
+ setSmtpLoading(true)
+ try {
+ const res = await fetch('/api/admin/settings', {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ action: 'save_contact_email', contactEmail }),
+ })
+ const data = await res.json()
+ if (data.success) toast({ title: 'Kontakt-E-Mail gespeichert' })
+ else throw new Error(data.error)
+ } catch (error) {
+ toast({ title: 'Fehler', description: error instanceof Error ? error.message : 'Fehler', variant: 'destructive' })
+ } finally { setSmtpLoading(false) }
+ }
+
+ const handleStripeSave = async () => {
+ setStripeLoading(true)
+ setStripeStatus(null)
+ try {
+ const res = await fetch('/api/admin/settings', {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ action: 'save_stripe',
+ stripe: {
+ publicKey: stripePublicKey,
+ secretKey: stripeSecretKey,
+ webhookSecret: stripeWebhookSecret,
+ },
+ }),
+ })
+ const data = await res.json()
+ if (data.success) {
+ setStripeStatus('saved')
+ toast({ title: 'Stripe-Einstellungen gespeichert' })
+ } else throw new Error(data.error)
+ } catch (error) {
+ setStripeStatus('error')
+ toast({ title: 'Fehler', description: error instanceof Error ? error.message : 'Fehler', variant: 'destructive' })
+ } finally { setStripeLoading(false) }
+ }
+
+ return (
+
+ {/* Contact Email */}
+
+
+
+ Kontakt-E-Mail
+
+
E-Mail-Adresse für das Kontaktformular auf der Landing Page. Hierhin werden Anfragen gesendet.
+
+
Empfänger-Adresse setContactEmail(e.target.value)} placeholder="app@lageplan.ch" />
+
Speichern
+
+
+
+ {/* Registration Notification */}
+
+
+
+ Registrierungs-Benachrichtigung
+
+
E-Mail-Adresse, an die bei neuen Registrierungen eine Benachrichtigung gesendet wird. Leer lassen = keine Benachrichtigung.
+
+
Admin-E-Mail setNotifyRegistrationEmail(e.target.value)} placeholder="admin@lageplan.ch" />
+
{
+ try {
+ const res = await fetch('/api/admin/settings', {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ action: 'save_setting', key: 'notify_registration_email', value: notifyRegistrationEmail }),
+ })
+ if ((await res.json()).success) toast({ title: 'Gespeichert' })
+ } catch { toast({ title: 'Fehler', variant: 'destructive' }) }
+ }} disabled={smtpLoading}>Speichern
+
+
+
+ {/* SMTP Settings */}
+
+
+
+ E-Mail / SMTP Konfiguration
+
+
SMTP-Server für den E-Mail-Versand konfigurieren. Empfohlen: TLS auf Port 587. Passwörter werden verschlüsselt in der Datenbank gespeichert.
+
+
+
+ {smtpLoading ? : null}
+ Speichern
+
+
+ Verbindung testen
+
+ {smtpStatus === 'connected' && Verbunden }
+ {smtpStatus === 'error' && Fehlgeschlagen }
+ {smtpStatus === 'saved' && Gespeichert }
+
+
+
Test-E-Mail senden
+
+ setSmtpTestEmail(e.target.value)} placeholder="empfaenger@example.com" className="max-w-xs" />
+
+
+ Senden
+
+
+
+
+
+ {/* Stripe Settings */}
+
+
+
+ Stripe / Spenden-Konfiguration
+
+
+ Stripe API-Keys für die Spendenseite konfigurieren. Unterstützt Kreditkarte, Twint und weitere Zahlungsmethoden.
+ Keys findest du im Stripe Dashboard .
+
+
+
+ Webhook-Endpoint: {typeof window !== 'undefined' ? window.location.origin : ''}/api/donate/webhook
+
+
+
+ {stripeLoading ? : null}
+ Speichern
+
+ {stripeStatus === 'saved' && Gespeichert }
+ {stripeStatus === 'error' && Fehlgeschlagen }
+
+
+
+ {/* Demo Project */}
+
+
+
+ Live-Demo auf der Startseite
+
+
+ Wähle ein Projekt als Demo-Karte für die Landing Page. Besucher können die Karte sehen und zoomen, aber nichts bearbeiten.
+
+
+
+ Demo-Projekt
+ setDemoProjectId(e.target.value)}
+ className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
+ >
+ — Keine Demo —
+ {allProjects.map(p => (
+ {p.title}{p.location ? ` (${p.location})` : ''}
+ ))}
+
+
+
{
+ setDemoLoading(true)
+ setDemoStatus(null)
+ try {
+ const res = await fetch('/api/admin/settings', {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ action: 'save_demo_project', demoProjectId }),
+ })
+ const data = await res.json()
+ if (data.success) {
+ toast({ title: 'Demo-Projekt gespeichert' })
+ setDemoStatus('saved')
+ } else throw new Error(data.error)
+ } catch (error) {
+ toast({ title: 'Fehler', description: error instanceof Error ? error.message : 'Fehler', variant: 'destructive' })
+ setDemoStatus('error')
+ } finally { setDemoLoading(false) }
+ }}
+ disabled={demoLoading}
+ >
+ {demoLoading ? : null}
+ Speichern
+
+ {demoStatus === 'saved' &&
Gespeichert}
+
+ {demoProjectId && (
+
+ Vorschau: /demo
+
+ )}
+
+
+ {/* Symbol-Grösse */}
+
+
+
+ Standard Symbol-Grösse
+
+
+ Bestimmt die Standard-Grösse neuer Symbole auf der Karte. Kleinere Werte = kleinere Symbole.
+
+
+ setDefaultSymbolScale(e.target.value)}
+ className="flex-1"
+ />
+ {defaultSymbolScale}x
+
+
+ 0.5x (klein)
+
+ 5x (gross)
+
+
+ {
+ setSymbolScaleLoading(true)
+ setSymbolScaleStatus(null)
+ try {
+ const res = await fetch('/api/admin/settings', {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ action: 'save_setting', key: 'default_symbol_scale', value: defaultSymbolScale }),
+ })
+ const data = await res.json()
+ if (data.success) setSymbolScaleStatus('saved')
+ } catch {} finally { setSymbolScaleLoading(false) }
+ }}
+ >
+ {symbolScaleLoading ? : null}
+ Speichern
+
+ {symbolScaleStatus === 'saved' && Gespeichert }
+
+
+
+ {/* App Info */}
+
+
+
+ System-Info
+
+
+
Version 1.0.0
+
Framework Next.js 14.1
+
Datenbank PostgreSQL 16
+
Benutzer {usersCount}
+
Mandanten {tenantsCount}
+
Symbole {iconsCount}
+
+
+
+ {/* Quick Actions */}
+
+
+
+ Schnellaktionen
+
+
+
onNavigateTab('tenants')}>
+
+ Mandanten verwalten
+
+
onNavigateTab('users')}>
+
+ Benutzer anlegen
+
+
+
+
+ Zur Krokier-App
+
+
+
+
+
+ )
+}
diff --git a/src/components/admin/soma-tab.tsx b/src/components/admin/soma-tab.tsx
new file mode 100644
index 0000000..446ea23
--- /dev/null
+++ b/src/components/admin/soma-tab.tsx
@@ -0,0 +1,147 @@
+'use client'
+
+import { useState, useEffect } from 'react'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { useToast } from '@/components/ui/use-toast'
+import { AlertTriangle, Eye, EyeOff, Trash2, Plus, Loader2, GripVertical } from 'lucide-react'
+
+interface SomaTemplate {
+ id: string
+ label: string
+ sortOrder: number
+ isActive: boolean
+}
+
+export function SomaTab() {
+ const { toast } = useToast()
+ const [somaTemplates, setSomaTemplates] = useState([])
+ const [newSomaLabel, setNewSomaLabel] = useState('')
+ const [somaLoading, setSomaLoading] = useState(false)
+
+ const fetchSomaTemplates = async () => {
+ setSomaLoading(true)
+ try {
+ const res = await fetch('/api/tenant/soma-templates')
+ if (res.ok) {
+ const data = await res.json()
+ setSomaTemplates(data.templates || [])
+ }
+ } catch {}
+ setSomaLoading(false)
+ }
+
+ useEffect(() => {
+ fetchSomaTemplates()
+ }, [])
+
+ const handleAdd = async () => {
+ if (!newSomaLabel.trim()) return
+ try {
+ await fetch('/api/tenant/soma-templates', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ label: newSomaLabel.trim(), sortOrder: somaTemplates.length }),
+ })
+ setNewSomaLabel('')
+ fetchSomaTemplates()
+ toast({ title: 'SOMA-Vorlage hinzugefügt' })
+ } catch {}
+ }
+
+ return (
+
+
+
+ SOMA-Checkliste verwalten
+
+
+ Definiere die Sofortmassnahmen (SOMA), die bei jedem neuen Einsatz als Checkliste erscheinen.
+ Bestehende Einsätze werden nicht verändert.
+
+
+ {somaLoading ? (
+
+ Laden...
+
+ ) : (
+ <>
+ {/* Template list */}
+
+ {somaTemplates.length === 0 ? (
+
+ Keine SOMA-Vorlagen definiert. Neue Einsätze starten ohne Checkliste.
+
+ ) : somaTemplates.map((tpl, idx) => (
+
+
+ {tpl.label}
+ #{idx + 1}
+ {
+ try {
+ await fetch('/api/tenant/soma-templates', {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ updates: [{ id: tpl.id, isActive: !tpl.isActive }] }),
+ })
+ fetchSomaTemplates()
+ } catch {}
+ }}
+ >
+ {tpl.isActive ? : }
+
+ {
+ if (!confirm(`"${tpl.label}" wirklich löschen?`)) return
+ try {
+ await fetch('/api/tenant/soma-templates', {
+ method: 'DELETE',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ id: tpl.id }),
+ })
+ fetchSomaTemplates()
+ toast({ title: 'SOMA-Vorlage gelöscht' })
+ } catch {}
+ }}
+ >
+
+
+
+ ))}
+
+
+ {/* Add new */}
+
+
setNewSomaLabel(e.target.value)}
+ onKeyDown={e => {
+ if (e.key === 'Enter' && newSomaLabel.trim()) {
+ e.preventDefault()
+ handleAdd()
+ }
+ }}
+ className="flex-1"
+ />
+
+ Hinzufügen
+
+
+
+
+ {somaTemplates.filter(t => t.isActive).length} aktiv / {somaTemplates.length} gesamt —
+ Nur aktive Vorlagen erscheinen bei neuen Einsätzen.
+
+ >
+ )}
+
+ )
+}
diff --git a/src/components/admin/suggestions-tab.tsx b/src/components/admin/suggestions-tab.tsx
new file mode 100644
index 0000000..566c80a
--- /dev/null
+++ b/src/components/admin/suggestions-tab.tsx
@@ -0,0 +1,150 @@
+'use client'
+
+import { useState, useEffect } from 'react'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { useToast } from '@/components/ui/use-toast'
+import { BookOpen, Plus, X, Download, Upload } from 'lucide-react'
+
+interface SuggestionsTabProps {
+ tenantId: string | undefined
+}
+
+export function SuggestionsTab({ tenantId }: SuggestionsTabProps) {
+ const { toast } = useToast()
+ const [journalSuggestions, setJournalSuggestions] = useState([])
+ const [newSuggestion, setNewSuggestion] = useState('')
+
+ useEffect(() => {
+ if (!tenantId) return
+ fetch(`/api/tenants/${tenantId}/suggestions`)
+ .then(r => r.ok ? r.json() : null)
+ .then(data => { if (data?.suggestions) setJournalSuggestions(data.suggestions) })
+ .catch(() => {})
+ }, [tenantId])
+
+ const saveSuggestions = (updated: string[]) => {
+ if (!tenantId) return
+ fetch(`/api/tenants/${tenantId}/suggestions`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ suggestions: updated }),
+ }).then(r => { if (r.ok) toast({ title: 'Gespeichert' }) })
+ }
+
+ const handleAdd = () => {
+ const trimmed = newSuggestion.trim()
+ if (!trimmed || journalSuggestions.includes(trimmed)) return
+ const updated = [...journalSuggestions, trimmed].sort((a, b) => a.localeCompare(b, 'de'))
+ setJournalSuggestions(updated)
+ setNewSuggestion('')
+ saveSuggestions(updated)
+ }
+
+ const handleRemove = (index: number) => {
+ const updated = journalSuggestions.filter((_, idx) => idx !== index)
+ setJournalSuggestions(updated)
+ saveSuggestions(updated)
+ toast({ title: 'Entfernt' })
+ }
+
+ return (
+
+
+
+ Journal-Wörterliste
+
+
+ Häufige Begriffe und Textbausteine, die beim Erfassen von Journal-Einträgen als Vorschläge erscheinen.
+ Wenn der Benutzer im "Was..."-Feld tippt, werden passende Begriffe vorgeschlagen.
+
+
+ {/* Add new suggestion */}
+
+
setNewSuggestion(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' && newSuggestion.trim()) handleAdd()
+ }}
+ className="flex-1"
+ />
+
+
+ Hinzufügen
+
+
+
+ {/* List of suggestions */}
+ {journalSuggestions.length === 0 ? (
+
+ Noch keine Begriffe hinterlegt. Fügen Sie häufig verwendete Textbausteine hinzu,
+ z.B. "Leitung aufbauen", "Leitung abbauen", "Lüfter in Stellung", etc.
+
+ ) : (
+
+ {journalSuggestions.map((s, i) => (
+
+ {s}
+ handleRemove(i)}
+ className="ml-1 hover:text-red-500 transition-colors"
+ >
+
+
+
+ ))}
+
+ )}
+
+
+
+ {journalSuggestions.length} Begriff(e) hinterlegt. Änderungen werden automatisch gespeichert.
+
+
{
+ const blob = new Blob([journalSuggestions.join('\n')], { type: 'text/plain' })
+ const url = URL.createObjectURL(blob)
+ const a = document.createElement('a')
+ a.href = url
+ a.download = 'woerterliste.txt'
+ a.click()
+ URL.revokeObjectURL(url)
+ toast({ title: 'Exportiert', description: `${journalSuggestions.length} Begriffe exportiert` })
+ }}
+ disabled={journalSuggestions.length === 0}
+ >
+
+ Export
+
+
{
+ const input = document.createElement('input')
+ input.type = 'file'
+ input.accept = '.txt,.csv'
+ input.onchange = async (e) => {
+ const file = (e.target as HTMLInputElement).files?.[0]
+ if (!file) return
+ const text = await file.text()
+ const words = text.split(/[\n\r,;]+/).map(w => w.trim()).filter(Boolean)
+ if (words.length === 0) { toast({ title: 'Keine Begriffe gefunden', variant: 'destructive' }); return }
+ const merged = Array.from(new Set([...journalSuggestions, ...words])).sort((a, b) => a.localeCompare(b, 'de'))
+ setJournalSuggestions(merged)
+ saveSuggestions(merged)
+ toast({ title: 'Importiert', description: `${words.length} Begriffe importiert (${merged.length} total)` })
+ }
+ input.click()
+ }}
+ >
+
+ Import
+
+
+
+ )
+}
diff --git a/src/components/admin/symbol-manager.tsx b/src/components/admin/symbol-manager.tsx
new file mode 100644
index 0000000..506e96d
--- /dev/null
+++ b/src/components/admin/symbol-manager.tsx
@@ -0,0 +1,372 @@
+'use client'
+
+import { useState, useEffect, useCallback } from 'react'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { useToast } from '@/components/ui/use-toast'
+import {
+ ChevronDown,
+ ChevronRight,
+ Plus,
+ Pencil,
+ Trash2,
+ Search,
+ Upload,
+ Loader2,
+ X,
+ Check,
+ LayoutGrid,
+ ImageIcon,
+ Info,
+} from 'lucide-react'
+
+interface LibraryIcon {
+ id: string
+ name: string
+ mimeType: string
+ iconType: string
+ categoryId: string
+ categoryName: string
+}
+
+interface MySymbol {
+ id: string
+ iconId: string
+ name: string
+ customName: string | null
+ baseName: string
+ mimeType: string
+ iconType: string
+ categoryName: string
+ sortOrder: number
+}
+
+export function SymbolManager() {
+ const { toast } = useToast()
+ const [loading, setLoading] = useState(true)
+ const [library, setLibrary] = useState([])
+ const [mySymbols, setMySymbols] = useState([])
+
+ // Library UI state
+ const [librarySearch, setLibrarySearch] = useState('')
+ const [expandedCategories, setExpandedCategories] = useState>(new Set())
+ const [libraryCollapsed, setLibraryCollapsed] = useState(false)
+
+ // My Symbols UI state
+ const [editingId, setEditingId] = useState(null)
+ const [editName, setEditName] = useState('')
+
+ const fetchData = useCallback(async () => {
+ setLoading(true)
+ try {
+ const res = await fetch('/api/tenant/symbols')
+ if (res.ok) {
+ const data = await res.json()
+ setLibrary(data.library || [])
+ setMySymbols(data.mySymbols || [])
+ // Auto-collapse library if tenant has own symbols
+ if ((data.mySymbols || []).length > 0) {
+ setLibraryCollapsed(true)
+ }
+ }
+ } catch {}
+ setLoading(false)
+ }, [])
+
+ useEffect(() => { fetchData() }, [fetchData])
+
+ // Add symbol from library to my collection
+ const addSymbol = async (iconId: string, customName?: string) => {
+ try {
+ const res = await fetch('/api/tenant/symbols', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ iconId, customName }),
+ })
+ if (res.ok) {
+ const symbol = await res.json()
+ setMySymbols(prev => [...prev, symbol])
+ toast({ title: 'Symbol hinzugefügt' })
+ }
+ } catch {
+ toast({ title: 'Fehler', variant: 'destructive' })
+ }
+ }
+
+ // Rename a symbol
+ const renameSymbol = async (id: string, customName: string) => {
+ try {
+ await fetch('/api/tenant/symbols', {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ id, customName }),
+ })
+ setMySymbols(prev => prev.map(s =>
+ s.id === id ? { ...s, name: customName || s.baseName, customName: customName || null } : s
+ ))
+ setEditingId(null)
+ setEditName('')
+ toast({ title: 'Umbenannt' })
+ } catch {
+ toast({ title: 'Fehler', variant: 'destructive' })
+ }
+ }
+
+ // Remove a symbol
+ const removeSymbol = async (id: string) => {
+ try {
+ await fetch('/api/tenant/symbols', {
+ method: 'DELETE',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ id }),
+ })
+ setMySymbols(prev => prev.filter(s => s.id !== id))
+ toast({ title: 'Symbol entfernt' })
+ } catch {
+ toast({ title: 'Fehler', variant: 'destructive' })
+ }
+ }
+
+ // Toggle category expand/collapse
+ const toggleCategory = (cat: string) => {
+ setExpandedCategories(prev => {
+ const next = new Set(prev)
+ next.has(cat) ? next.delete(cat) : next.add(cat)
+ return next
+ })
+ }
+
+ // Group library icons by category
+ const filteredLibrary = library.filter(icon =>
+ !librarySearch || icon.name.toLowerCase().includes(librarySearch.toLowerCase())
+ )
+ const libraryGrouped = filteredLibrary.reduce>((acc, icon) => {
+ const key = icon.categoryName
+ if (!acc[key]) acc[key] = []
+ acc[key].push(icon)
+ return acc
+ }, {})
+
+ if (loading) {
+ return (
+
+ Symbole laden...
+
+ )
+ }
+
+ return (
+
+ {/* ===== MEINE SYMBOLE (always on top, prominent) ===== */}
+
+
+
+
+ Meine Symbole
+ ({mySymbols.length})
+
+
+
+ {mySymbols.length === 0 ? (
+
+
+
Noch keine eigenen Symbole definiert.
+
+ Füge Symbole aus der Bibliothek unten hinzu oder lade eigene SVGs hoch.
+
+
+ ) : (
+
+
+ {mySymbols.map(sym => (
+
+
+
+
+
+ {/* Name / Edit */}
+ {editingId === sym.id ? (
+
+ setEditName(e.target.value)}
+ onKeyDown={e => {
+ if (e.key === 'Enter') renameSymbol(sym.id, editName)
+ if (e.key === 'Escape') { setEditingId(null); setEditName('') }
+ }}
+ className="h-6 text-[10px] px-1"
+ autoFocus
+ />
+ renameSymbol(sym.id, editName)} className="text-green-600 hover:text-green-700">
+
+
+ { setEditingId(null); setEditName('') }} className="text-muted-foreground hover:text-foreground">
+
+
+
+ ) : (
+
{ setEditingId(sym.id); setEditName(sym.name) }}
+ >
+ {sym.name}
+
+ )}
+
+ {/* Hover actions */}
+
+
{ setEditingId(sym.id); setEditName(sym.name) }}
+ className="w-5 h-5 rounded bg-background/80 border flex items-center justify-center text-muted-foreground hover:text-primary"
+ title="Umbenennen"
+ >
+
+
+
removeSymbol(sym.id)}
+ className="w-5 h-5 rounded bg-background/80 border flex items-center justify-center text-muted-foreground hover:text-destructive"
+ title="Entfernen"
+ >
+
+
+
+
+ {/* Custom name badge */}
+ {sym.customName && (
+
+ )}
+
+ ))}
+
+
+ )}
+
+
+ {/* ===== UPLOAD HINWEIS ===== */}
+
+
+
+ Tipp: Eigene Symbole am besten als SVG hochladen — diese werden in jeder Grösse scharf dargestellt.
+ PNG/JPEG sind auch möglich, können aber bei Vergrösserung unscharf werden.
+
+
+
+ {/* ===== STANDARD-BIBLIOTHEK (collapsible) ===== */}
+
+
setLibraryCollapsed(!libraryCollapsed)}
+ >
+
+ {libraryCollapsed ? : }
+ Standard-Bibliothek
+ ({library.length} Symbole)
+
+
+ {libraryCollapsed ? 'Aufklappen' : 'Zuklappen'}
+
+
+
+ {!libraryCollapsed && (
+
+ {/* Search */}
+
+
+ setLibrarySearch(e.target.value)}
+ className="pl-9"
+ />
+
+
+ {/* Categories */}
+ {Object.entries(libraryGrouped).sort(([a], [b]) => a.localeCompare(b)).map(([catName, icons]) => (
+
+
toggleCategory(catName)}
+ >
+
+ {expandedCategories.has(catName) ? : }
+ {catName}
+ ({icons.length})
+
+ {
+ e.stopPropagation()
+ // Add all icons from this category
+ icons.forEach(icon => addSymbol(icon.id))
+ }}
+ >
+ Alle hinzufügen
+
+
+
+ {expandedCategories.has(catName) && (
+
+
+ {icons.map(icon => {
+ const alreadyAdded = mySymbols.some(s => s.iconId === icon.id)
+ return (
+
addSymbol(icon.id)}
+ className={`group relative border rounded-lg p-2 transition-all hover:shadow-sm hover:border-primary/40 ${
+ alreadyAdded ? 'bg-green-50 dark:bg-green-950/20 border-green-200 dark:border-green-800' : ''
+ }`}
+ title={`${icon.name} — Klick zum Hinzufügen`}
+ >
+
+
+
+ {icon.name}
+ {/* Add overlay */}
+
+ {/* Already added indicator */}
+ {alreadyAdded && (
+
+
+
+ )}
+
+ )
+ })}
+
+
+ )}
+
+ ))}
+
+ {Object.keys(libraryGrouped).length === 0 && (
+
+ {librarySearch ? 'Keine Symbole gefunden.' : 'Keine Standard-Symbole vorhanden.'}
+
+ )}
+
+ )}
+
+
+ )
+}
diff --git a/src/components/error-boundary.tsx b/src/components/error-boundary.tsx
new file mode 100644
index 0000000..fc01504
--- /dev/null
+++ b/src/components/error-boundary.tsx
@@ -0,0 +1,58 @@
+'use client'
+
+import React from 'react'
+import { AlertTriangle, RotateCcw } from 'lucide-react'
+import { Button } from '@/components/ui/button'
+
+interface ErrorBoundaryProps {
+ children: React.ReactNode
+ fallback?: React.ReactNode
+}
+
+interface ErrorBoundaryState {
+ hasError: boolean
+ error: Error | null
+}
+
+export class ErrorBoundary extends React.Component {
+ constructor(props: ErrorBoundaryProps) {
+ super(props)
+ this.state = { hasError: false, error: null }
+ }
+
+ static getDerivedStateFromError(error: Error): ErrorBoundaryState {
+ return { hasError: true, error }
+ }
+
+ componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
+ console.error('[ErrorBoundary] Caught error:', error, errorInfo)
+ }
+
+ handleReset = () => {
+ this.setState({ hasError: false, error: null })
+ }
+
+ render() {
+ if (this.state.hasError) {
+ if (this.props.fallback) {
+ return this.props.fallback
+ }
+
+ return (
+
+
+
Etwas ist schiefgelaufen
+
+ {this.state.error?.message || 'Ein unerwarteter Fehler ist aufgetreten.'}
+
+
+
+ Erneut versuchen
+
+
+ )
+ }
+
+ return this.props.children
+ }
+}
diff --git a/src/components/journal/journal-view.tsx b/src/components/journal/journal-view.tsx
index 4fbd189..6979145 100644
--- a/src/components/journal/journal-view.tsx
+++ b/src/components/journal/journal-view.tsx
@@ -9,6 +9,7 @@ import {
AlertTriangle, ClipboardList, Loader2, Printer, Pencil, Send, FileText,
} from 'lucide-react'
import { getSocket } from '@/lib/socket'
+import { RapportDialog } from '@/components/journal/rapport-dialog'
interface JournalEntry {
id: string
@@ -86,7 +87,6 @@ export function JournalView({ projectId, projectTitle, projectLocation, einsatzl
const scrollRef = useRef(null)
// Rapport creation
- const [creatingRapport, setCreatingRapport] = useState(false)
const [lastRapportLink, setLastRapportLink] = useState(null)
const [showRapportDialog, setShowRapportDialog] = useState(false)
const [rapportForm, setRapportForm] = useState>({})
@@ -895,210 +895,16 @@ export function JournalView({ projectId, projectTitle, projectLocation, einsatzl
{/* Rapport Dialog */}
- {showRapportDialog && (
-
-
-
-
-
- Einsatzrapport erstellen
-
- setShowRapportDialog(false)} className="text-gray-400 hover:text-gray-600 text-xl leading-none">×
-
-
- {/* Organisation */}
-
- {/* Einsatzdaten */}
-
- {/* Ort */}
-
-
-
- Alarmierungsart
- setRapportForm(f => ({ ...f, alarmierungsart: e.target.value }))} />
-
-
-
- Stichwort / Meldebild
- setRapportForm(f => ({ ...f, stichwort: e.target.value }))} />
-
- {/* Zeitverlauf */}
-
- {/* Lagebild */}
-
- Lage bei Eintreffen
-
- {/* Massnahmen (read-only, from journal) */}
-
-
Massnahmen (aus Journal)
-
- {Array.isArray(rapportForm.massnahmen) && rapportForm.massnahmen.length > 0 ? (
- rapportForm.massnahmen.map((m: string, i: number) =>
• {m}
)
- ) :
Keine Einträge }
-
-
- {/* Bemerkungen */}
-
- Bemerkungen
-
- {/* Unterschriften */}
-
-
-
- setShowRapportDialog(false)}>Abbrechen
- {
- if (!projectId) return
- setCreatingRapport(true)
- try {
- // Capture map screenshot — compress to JPEG and resize for smaller payload
- let mapScreenshot = ''
- const rawScreenshot = preCapuredScreenshot || ''
- if (!rawScreenshot) {
- try {
- if (mapRef?.current) {
- const canvas = mapRef.current.getCanvas()
- if (canvas) {
- // Resize to max 2400px wide and convert to JPEG
- const maxW = 2400
- const ratio = Math.min(1, maxW / canvas.width)
- const offscreen = document.createElement('canvas')
- offscreen.width = Math.round(canvas.width * ratio)
- offscreen.height = Math.round(canvas.height * ratio)
- const ctx = offscreen.getContext('2d')
- if (ctx) {
- ctx.drawImage(canvas, 0, 0, offscreen.width, offscreen.height)
- mapScreenshot = offscreen.toDataURL('image/jpeg', 0.85)
- }
- }
- }
- } catch (e) { console.warn('Map screenshot failed:', e) }
- } else if (rawScreenshot.length > 800000) {
- // Compress pre-captured screenshot if too large
- try {
- const img = new Image()
- img.src = rawScreenshot
- await new Promise(r => { img.onload = r; img.onerror = r })
- const maxW = 2400
- const ratio = Math.min(1, maxW / img.naturalWidth)
- const offscreen = document.createElement('canvas')
- offscreen.width = Math.round(img.naturalWidth * ratio)
- offscreen.height = Math.round(img.naturalHeight * ratio)
- const ctx = offscreen.getContext('2d')
- if (ctx) {
- ctx.drawImage(img, 0, 0, offscreen.width, offscreen.height)
- mapScreenshot = offscreen.toDataURL('image/jpeg', 0.85)
- }
- } catch { mapScreenshot = rawScreenshot }
- } else {
- mapScreenshot = rawScreenshot
- }
- // Convert logo URL to base64 for PDF rendering
- let logoDataUri = ''
- if (rapportForm.logoUrl) {
- try {
- const logoRes = await fetch(rapportForm.logoUrl)
- if (logoRes.ok) {
- const blob = await logoRes.blob()
- logoDataUri = await new Promise((resolve) => {
- const reader = new FileReader()
- reader.onloadend = () => resolve(reader.result as string)
- reader.readAsDataURL(blob)
- })
- }
- } catch (e) { console.warn('Logo fetch failed:', e) }
- }
- const rapportData = { ...rapportForm, mapScreenshot, logoUrl: logoDataUri || rapportForm.logoUrl }
- console.log('[Rapport] Sending request, body size ~', JSON.stringify({ projectId, data: rapportData }).length, 'bytes')
- const res = await fetch('/api/rapports', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ projectId, data: rapportData }),
- })
- if (res.ok) {
- const result = await res.json()
- setLastRapportLink(`/rapport/${result.token}`)
- setShowRapportDialog(false)
- window.open(`/rapport/${result.token}`, '_blank')
- } else {
- const errData = await res.json().catch(() => ({ error: `HTTP ${res.status}` }))
- console.error('[Rapport] API error:', res.status, errData)
- alert(`Rapport-Fehler: ${errData.error || 'Unbekannter Fehler (Status ' + res.status + ')'}`)
- }
- } catch (err: any) {
- console.error('Error creating rapport:', err)
- alert('Rapport-Fehler: ' + (err?.message || 'Netzwerkfehler'))
- } finally {
- setCreatingRapport(false)
- }
- }}
- >
- {creatingRapport ? : }
- Rapport generieren
-
-
-
-
+ {showRapportDialog && projectId && (
+
setShowRapportDialog(false)}
+ onRapportCreated={(link) => setLastRapportLink(link)}
+ />
)}
>
)
diff --git a/src/components/journal/rapport-dialog.tsx b/src/components/journal/rapport-dialog.tsx
new file mode 100644
index 0000000..cf74f3b
--- /dev/null
+++ b/src/components/journal/rapport-dialog.tsx
@@ -0,0 +1,236 @@
+'use client'
+
+import { useState, MutableRefObject } from 'react'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { FileText, Loader2 } from 'lucide-react'
+
+interface RapportDialogProps {
+ projectId: string
+ rapportForm: Record
+ setRapportForm: React.Dispatch>>
+ mapRef?: MutableRefObject
+ mapScreenshot?: string
+ onClose: () => void
+ onRapportCreated: (link: string) => void
+}
+
+export function RapportDialog({
+ projectId,
+ rapportForm,
+ setRapportForm,
+ mapRef,
+ mapScreenshot: preCapuredScreenshot,
+ onClose,
+ onRapportCreated,
+}: RapportDialogProps) {
+ const [creatingRapport, setCreatingRapport] = useState(false)
+
+ const handleCreate = async () => {
+ if (!projectId) return
+ setCreatingRapport(true)
+ try {
+ // Capture map screenshot — compress to JPEG and resize for smaller payload
+ let mapScreenshot = ''
+ const rawScreenshot = preCapuredScreenshot || ''
+ if (!rawScreenshot) {
+ try {
+ if (mapRef?.current) {
+ const canvas = mapRef.current.getCanvas()
+ if (canvas) {
+ // Resize to max 2400px wide and convert to JPEG
+ const maxW = 2400
+ const ratio = Math.min(1, maxW / canvas.width)
+ const offscreen = document.createElement('canvas')
+ offscreen.width = Math.round(canvas.width * ratio)
+ offscreen.height = Math.round(canvas.height * ratio)
+ const ctx = offscreen.getContext('2d')
+ if (ctx) {
+ ctx.drawImage(canvas, 0, 0, offscreen.width, offscreen.height)
+ mapScreenshot = offscreen.toDataURL('image/jpeg', 0.85)
+ }
+ }
+ }
+ } catch (e) { console.warn('Map screenshot failed:', e) }
+ } else if (rawScreenshot.length > 800000) {
+ // Compress pre-captured screenshot if too large
+ try {
+ const img = new Image()
+ img.src = rawScreenshot
+ await new Promise(r => { img.onload = r; img.onerror = r })
+ const maxW = 2400
+ const ratio = Math.min(1, maxW / img.naturalWidth)
+ const offscreen = document.createElement('canvas')
+ offscreen.width = Math.round(img.naturalWidth * ratio)
+ offscreen.height = Math.round(img.naturalHeight * ratio)
+ const ctx = offscreen.getContext('2d')
+ if (ctx) {
+ ctx.drawImage(img, 0, 0, offscreen.width, offscreen.height)
+ mapScreenshot = offscreen.toDataURL('image/jpeg', 0.85)
+ }
+ } catch { mapScreenshot = rawScreenshot }
+ } else {
+ mapScreenshot = rawScreenshot
+ }
+ // Convert logo URL to base64 for PDF rendering
+ let logoDataUri = ''
+ if (rapportForm.logoUrl) {
+ try {
+ const logoRes = await fetch(rapportForm.logoUrl)
+ if (logoRes.ok) {
+ const blob = await logoRes.blob()
+ logoDataUri = await new Promise((resolve) => {
+ const reader = new FileReader()
+ reader.onloadend = () => resolve(reader.result as string)
+ reader.readAsDataURL(blob)
+ })
+ }
+ } catch (e) { console.warn('Logo fetch failed:', e) }
+ }
+ const rapportData = { ...rapportForm, mapScreenshot, logoUrl: logoDataUri || rapportForm.logoUrl }
+ console.log('[Rapport] Sending request, body size ~', JSON.stringify({ projectId, data: rapportData }).length, 'bytes')
+ const res = await fetch('/api/rapports', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ projectId, data: rapportData }),
+ })
+ if (res.ok) {
+ const result = await res.json()
+ onRapportCreated(`/rapport/${result.token}`)
+ onClose()
+ window.open(`/rapport/${result.token}`, '_blank')
+ } else {
+ const errData = await res.json().catch(() => ({ error: `HTTP ${res.status}` }))
+ console.error('[Rapport] API error:', res.status, errData)
+ alert(`Rapport-Fehler: ${errData.error || 'Unbekannter Fehler (Status ' + res.status + ')'}`)
+ }
+ } catch (err: any) {
+ console.error('Error creating rapport:', err)
+ alert('Rapport-Fehler: ' + (err?.message || 'Netzwerkfehler'))
+ } finally {
+ setCreatingRapport(false)
+ }
+ }
+
+ return (
+
+
+
+
+
+ Einsatzrapport erstellen
+
+ ×
+
+
+ {/* Organisation */}
+
+ {/* Einsatzdaten */}
+
+ {/* Ort */}
+
+
+
+ Alarmierungsart
+ setRapportForm(f => ({ ...f, alarmierungsart: e.target.value }))} />
+
+
+
+ Stichwort / Meldebild
+ setRapportForm(f => ({ ...f, stichwort: e.target.value }))} />
+
+ {/* Zeitverlauf */}
+
+ {/* Lagebild */}
+
+ Lage bei Eintreffen
+
+ {/* Massnahmen (read-only, from journal) */}
+
+
Massnahmen (aus Journal)
+
+ {Array.isArray(rapportForm.massnahmen) && rapportForm.massnahmen.length > 0 ? (
+ rapportForm.massnahmen.map((m: string, i: number) =>
• {m}
)
+ ) :
Keine Einträge }
+
+
+ {/* Bemerkungen */}
+
+ Bemerkungen
+
+ {/* Unterschriften */}
+
+
+
+ Abbrechen
+
+ {creatingRapport ? : }
+ Rapport generieren
+
+
+
+
+ )
+}
diff --git a/src/components/layout/right-sidebar.tsx b/src/components/layout/right-sidebar.tsx
index 7f08981..75d122d 100644
--- a/src/components/layout/right-sidebar.tsx
+++ b/src/components/layout/right-sidebar.tsx
@@ -126,9 +126,26 @@ export function RightSidebar({ onSymbolDrop, canEdit, isOpen, onToggle, activeTa
// Separate tenant-specific icons ("Eigene" category) from global library
const eigene = allCats.find(c => c.name === 'Eigene')
const globalCats = allCats.filter(c => c.name !== 'Eigene')
- setTenantIcons(eigene?.symbols || [])
+
+ // Merge: mySymbols (custom collection) + legacy "Eigene" category uploads
+ const mySymbols: DisplaySymbol[] = (data.mySymbols || []).map((s: any) => ({
+ id: s.id,
+ name: s.name,
+ imageUrl: s.url || `/api/icons/${s.id}/image`,
+ }))
+ const legacyOwn = eigene?.symbols || []
+ // Deduplicate: mySymbols takes priority over legacy
+ const mySymbolIds = new Set(mySymbols.map(s => s.id))
+ const mergedTenant = [...mySymbols, ...legacyOwn.filter(s => !mySymbolIds.has(s.id))]
+
+ setTenantIcons(mergedTenant)
setCategories(globalCats)
if (globalCats.length > 0) setActiveCategory(globalCats[0].id)
+
+ // Auto-collapse library if tenant has own symbols
+ if (mergedTenant.length > 0) {
+ setShowLibrarySection(false)
+ }
}
} catch (err) {
console.error('Failed to load icons:', err)
diff --git a/src/components/map/map-view.tsx b/src/components/map/map-view.tsx
index 34f7e04..fc5e959 100644
--- a/src/components/map/map-view.tsx
+++ b/src/components/map/map-view.tsx
@@ -1472,21 +1472,27 @@ export function MapView({
midpoint = [cx / len, cy / len]
}
+ // Apply stored label offset if present
+ const labelOffset = f.properties.labelOffset as [number, number] | undefined
+ if (labelOffset) {
+ midpoint = [midpoint[0] + labelOffset[0], midpoint[1] + labelOffset[1]]
+ }
+
const el = document.createElement('div')
const isDanger = f.type === 'dangerzone'
el.style.cssText = `
- background: ${isDanger ? 'rgba(220,38,38,0.85)' : 'rgba(0,0,0,0.75)'};
+ background: ${isDanger ? 'rgba(220,38,38,0.85)' : 'rgba(0,0,0,0.82)'};
color: #fff;
- padding: 1px 5px;
- border-radius: 3px;
- font-size: 11px;
+ padding: 3px 8px;
+ border-radius: 4px;
+ font-size: 13px;
font-weight: 600;
white-space: nowrap;
pointer-events: ${canEdit ? 'auto' : 'none'};
letter-spacing: 0.3px;
border: 1px solid ${isDanger ? '#dc2626' : 'rgba(255,255,255,0.4)'};
- box-shadow: 0 1px 3px rgba(0,0,0,0.25);
- cursor: ${canEdit ? 'pointer' : 'default'};
+ box-shadow: 0 1px 4px rgba(0,0,0,0.3);
+ cursor: ${canEdit ? 'grab' : 'default'};
transform: translate(0,0);
will-change: transform;
`
@@ -1503,11 +1509,11 @@ export function MapView({
const labelLine = document.createElement('div')
labelLine.textContent = label
- labelLine.style.cssText = 'font-size:11px;font-weight:600;line-height:1.2;'
+ labelLine.style.cssText = 'font-size:13px;font-weight:700;line-height:1.3;'
const infoLine = document.createElement('div')
infoLine.textContent = `${lenText} / ${hoseCount} Schl.`
- infoLine.style.cssText = 'font-size:8px;opacity:0.8;line-height:1.2;font-weight:400;'
+ infoLine.style.cssText = 'font-size:10px;opacity:0.85;line-height:1.3;font-weight:500;'
el.appendChild(labelLine)
el.appendChild(infoLine)
@@ -1519,11 +1525,11 @@ export function MapView({
const labelLine = document.createElement('div')
labelLine.textContent = label
- labelLine.style.cssText = 'font-size:11px;font-weight:600;line-height:1.2;'
+ labelLine.style.cssText = 'font-size:13px;font-weight:700;line-height:1.3;'
const infoLine = document.createElement('div')
infoLine.textContent = areaText
- infoLine.style.cssText = 'font-size:8px;opacity:0.8;line-height:1.2;font-weight:400;'
+ infoLine.style.cssText = 'font-size:10px;opacity:0.85;line-height:1.3;font-weight:500;'
el.appendChild(labelLine)
el.appendChild(infoLine)
@@ -1539,9 +1545,41 @@ export function MapView({
})
}
- const marker = new maplibregl.Marker({ element: el, anchor: 'center', rotationAlignment: 'viewport' })
+ const marker = new maplibregl.Marker({ element: el, anchor: 'center', draggable: canEdit, rotationAlignment: 'viewport' })
.setLngLat(midpoint)
.addTo(map.current)
+
+ // Save label position offset on drag end
+ if (canEdit) {
+ marker.on('dragend', () => {
+ const newPos = marker.getLngLat()
+ // Calculate midpoint without offset to get the base midpoint
+ let baseMid: [number, number]
+ const feat = featuresRef.current.find(feat => feat.id === f.id)
+ if (!feat) return
+ if (feat.geometry.type === 'LineString') {
+ const coords = feat.geometry.coordinates as number[][]
+ const midIdx = Math.floor(coords.length / 2)
+ if (coords.length === 2) {
+ baseMid = [(coords[0][0] + coords[1][0]) / 2, (coords[0][1] + coords[1][1]) / 2]
+ } else {
+ baseMid = coords[midIdx] as [number, number]
+ }
+ } else {
+ const ring = (feat.geometry.coordinates as number[][][])[0]
+ const len = ring.length - 1
+ let cx = 0, cy = 0
+ for (let i = 0; i < len; i++) { cx += ring[i][0]; cy += ring[i][1] }
+ baseMid = [cx / len, cy / len]
+ }
+ const offset: [number, number] = [newPos.lng - baseMid[0], newPos.lat - baseMid[1]]
+ const updated = featuresRef.current.map(pf =>
+ pf.id === f.id ? { ...pf, properties: { ...pf.properties, labelOffset: offset } } : pf
+ )
+ onFeaturesChangeRef.current(updated)
+ })
+ }
+
markersRef.current.push(marker)
})
diff --git a/src/hooks/use-auto-save.ts b/src/hooks/use-auto-save.ts
new file mode 100644
index 0000000..e762d85
--- /dev/null
+++ b/src/hooks/use-auto-save.ts
@@ -0,0 +1,101 @@
+import { useCallback, useEffect, useRef } from 'react'
+import type { DrawFeature, Project } from '@/types'
+import { addToSyncQueue, getSyncQueue } from '@/lib/offline-sync'
+
+interface UseAutoSaveOptions {
+ currentProject: Project | null
+ features: DrawFeature[]
+ featuresRef: React.MutableRefObject
+ mapRef: React.MutableRefObject
+ socketRef: React.MutableRefObject
+ isEditingByMe: boolean
+ setSyncQueueCount: (count: number) => void
+}
+
+export function useAutoSave({
+ currentProject,
+ features,
+ featuresRef,
+ mapRef,
+ socketRef,
+ isEditingByMe,
+ setSyncQueueCount,
+}: UseAutoSaveOptions) {
+ // Persist features to localStorage on change (including empty array to reflect deletions)
+ useEffect(() => {
+ localStorage.setItem('lageplan-features', JSON.stringify(features))
+ }, [features])
+
+ // Auto-save to API — debounced 2s after every feature change + fallback interval
+ const saveTimerRef = useRef | null>(null)
+ const saveFeaturesToApi = useCallback(async () => {
+ if (!currentProject?.id) return
+ const url = `/api/projects/${currentProject.id}/features`
+ const mapInstance = mapRef.current
+ const body: any = { features: featuresRef.current }
+ if (mapInstance) {
+ const c = mapInstance.getCenter()
+ body.mapCenter = { lng: c.lng, lat: c.lat }
+ body.mapZoom = mapInstance.getZoom()
+ }
+
+ // If offline, queue the save for later sync
+ if (!navigator.onLine) {
+ addToSyncQueue(url, 'PUT', body)
+ setSyncQueueCount(getSyncQueue().length)
+ console.log('[Auto-Save] Offline — in Sync-Queue gespeichert')
+ return
+ }
+
+ try {
+ const res = await fetch(url, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ })
+ if (res.ok) {
+ console.log('[Auto-Save] Features gespeichert')
+ socketRef.current?.emit('features-updated', {
+ projectId: currentProject.id,
+ features: featuresRef.current,
+ })
+ } else if (res.status === 404) {
+ console.warn('[Auto-Save] Projekt nicht in DB')
+ }
+ } catch (e) {
+ // Network error — queue for later
+ addToSyncQueue(url, 'PUT', body)
+ setSyncQueueCount(getSyncQueue().length)
+ console.warn('[Auto-Save] Netzwerkfehler — in Sync-Queue:', e)
+ }
+ }, [currentProject, mapRef, featuresRef, socketRef, setSyncQueueCount])
+
+ // Debounced save on every feature change (2s delay)
+ useEffect(() => {
+ if (!currentProject || !isEditingByMe) return
+ if (saveTimerRef.current) clearTimeout(saveTimerRef.current)
+ saveTimerRef.current = setTimeout(() => saveFeaturesToApi(), 2000)
+ return () => { if (saveTimerRef.current) clearTimeout(saveTimerRef.current) }
+ }, [features, currentProject, isEditingByMe, saveFeaturesToApi])
+
+ // Also save on page unload / tab switch
+ useEffect(() => {
+ const handleBeforeUnload = () => {
+ if (currentProject?.id && featuresRef.current.length > 0) {
+ const payload = JSON.stringify({ features: featuresRef.current })
+ navigator.sendBeacon(`/api/projects/${currentProject.id}/features`, new Blob([payload], { type: 'application/json' }))
+ }
+ }
+ const handleVisibilityChange = () => {
+ if (document.visibilityState === 'hidden' && currentProject?.id && isEditingByMe) {
+ saveFeaturesToApi()
+ }
+ }
+ window.addEventListener('beforeunload', handleBeforeUnload)
+ document.addEventListener('visibilitychange', handleVisibilityChange)
+ return () => {
+ window.removeEventListener('beforeunload', handleBeforeUnload)
+ document.removeEventListener('visibilitychange', handleVisibilityChange)
+ }
+ }, [currentProject, isEditingByMe, saveFeaturesToApi, featuresRef])
+}
diff --git a/src/hooks/use-keyboard-shortcuts.ts b/src/hooks/use-keyboard-shortcuts.ts
new file mode 100644
index 0000000..05cf62d
--- /dev/null
+++ b/src/hooks/use-keyboard-shortcuts.ts
@@ -0,0 +1,74 @@
+import { useEffect, useCallback } from 'react'
+import type { DrawMode, DrawFeature } from '@/types'
+
+interface UseKeyboardShortcutsOptions {
+ featuresRef: React.MutableRefObject
+ onUndo: () => void
+ onRedo: () => void
+ onSave: () => void
+ onDelete: (newFeatures: DrawFeature[]) => void
+ onToolChange: (mode: DrawMode) => void
+ onHelpOpen: () => void
+}
+
+const TOOL_SHORTCUTS: Record = {
+ 'v': 'select', 's': 'select',
+ 'p': 'point',
+ 'l': 'linestring',
+ 'g': 'polygon',
+ 'r': 'rectangle',
+ 'c': 'circle',
+ 'f': 'freehand',
+ 'a': 'arrow',
+ 't': 'text',
+ 'e': 'eraser',
+ 'm': 'measure',
+ 'd': 'dangerzone',
+}
+
+export function useKeyboardShortcuts({
+ featuresRef,
+ onUndo,
+ onRedo,
+ onSave,
+ onDelete,
+ onToolChange,
+ onHelpOpen,
+}: UseKeyboardShortcutsOptions) {
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ // Ignore when typing in inputs/textareas
+ const tag = (e.target as HTMLElement)?.tagName
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || (e.target as HTMLElement)?.isContentEditable) return
+
+ // ? or F1 → help
+ if (e.key === '?' || e.key === 'F1') { e.preventDefault(); onHelpOpen(); return }
+
+ // DEL / Backspace → delete selected feature(s)
+ if (e.key === 'Delete' || e.key === 'Backspace') {
+ e.preventDefault()
+ const current = featuresRef.current
+ const selected = current.filter(f => f.properties?._selected)
+ if (selected.length > 0) {
+ onDelete(current.filter(f => !f.properties?._selected))
+ }
+ return
+ }
+
+ // Ctrl/Cmd shortcuts
+ if (e.ctrlKey || e.metaKey) {
+ if (e.key === 'z' && e.shiftKey) { e.preventDefault(); onRedo(); return }
+ if (e.key === 'z') { e.preventDefault(); onUndo(); return }
+ if (e.key === 'y') { e.preventDefault(); onRedo(); return }
+ if (e.key === 's') { e.preventDefault(); onSave(); return }
+ return
+ }
+
+ // Tool shortcuts (single key, no modifier)
+ const mode = TOOL_SHORTCUTS[e.key.toLowerCase()]
+ if (mode) { e.preventDefault(); onToolChange(mode); return }
+ }
+ window.addEventListener('keydown', handleKeyDown)
+ return () => window.removeEventListener('keydown', handleKeyDown)
+ }, [featuresRef, onUndo, onRedo, onSave, onDelete, onToolChange, onHelpOpen])
+}
diff --git a/src/hooks/use-map-export.ts b/src/hooks/use-map-export.ts
new file mode 100644
index 0000000..8c39d10
--- /dev/null
+++ b/src/hooks/use-map-export.ts
@@ -0,0 +1,329 @@
+import { useCallback } from 'react'
+import { jsPDF } from 'jspdf'
+import type { DrawFeature, Project } from '@/types'
+
+interface UseMapExportOptions {
+ mapRef: React.MutableRefObject
+ featuresRef: React.MutableRefObject
+ currentProject: Project | null
+ tenant: { id: string; name: string } | null
+ addAudit: (action: string) => void
+ toast: (opts: { title: string; description?: string; variant?: string }) => void
+}
+
+export function useMapExport({
+ mapRef,
+ featuresRef,
+ currentProject,
+ tenant,
+ addAudit,
+ toast,
+}: UseMapExportOptions) {
+ const handleExport = useCallback(async (format: 'png' | 'pdf') => {
+ const mapInstance = mapRef.current
+ if (!mapInstance) {
+ toast({ title: 'Fehler', description: 'Karte nicht bereit.', variant: 'destructive' })
+ return
+ }
+
+ try {
+ // 1. Get the MapLibre canvas (tiles + vector drawings)
+ const mapCanvas = mapInstance.getCanvas() as HTMLCanvasElement
+ const w = mapCanvas.width
+ const h = mapCanvas.height
+
+ // 2. Create composite canvas
+ const exportCanvas = document.createElement('canvas')
+ exportCanvas.width = w
+ exportCanvas.height = h
+ const ctx = exportCanvas.getContext('2d')!
+ ctx.drawImage(mapCanvas, 0, 0)
+
+ // 3. Draw symbols manually at correct size/rotation
+ const currentFeatures = featuresRef.current
+ // Derive actual pixel ratio from canvas vs container (more reliable than window.devicePixelRatio)
+ const container = mapInstance.getContainer()
+ const dpr = mapCanvas.width / container.offsetWidth
+ const zoom = mapInstance.getZoom()
+ // Symbol sizing: match the map rendering logic exactly
+ // In map-view.tsx: size = baseSize * scale * Math.pow(2, currentZoom - placementZoom)
+ const currentZoom = zoom
+
+ // Helper: load image as promise
+ const loadImage = (src: string): Promise => new Promise((resolve, reject) => {
+ const img = new Image()
+ img.crossOrigin = 'anonymous'
+ img.onload = () => resolve(img)
+ img.onerror = reject
+ img.src = src
+ })
+
+ // Draw symbol features
+ for (const f of currentFeatures.filter(f => f.type === 'symbol')) {
+ if (f.geometry.type !== 'Point') continue
+ const coords = f.geometry.coordinates as [number, number]
+ const pixel = mapInstance.project(coords)
+ const px = pixel.x * dpr
+ const py = pixel.y * dpr
+
+ const scale = (f.properties.scale as number) || 1
+ const rotation = (f.properties.rotation as number) || 0
+ const baseSize = 32
+ const placementZoom = (f.properties.placementZoom as number) || 17
+ const zoomFactor = Math.pow(2, currentZoom - placementZoom)
+ const size = Math.max(8, Math.min(400, baseSize * scale * zoomFactor)) * dpr
+
+ // Determine image source
+ const iconId = f.properties.iconId as string
+ const imageUrl = f.properties.imageUrl as string
+ let imgSrc = imageUrl || ''
+ if (!imgSrc && iconId) {
+ const { getSymbolById, getSymbolDataUri } = await import('@/lib/fw-symbols')
+ const sym = getSymbolById(iconId)
+ if (sym) imgSrc = getSymbolDataUri(sym)
+ }
+
+ if (imgSrc) {
+ try {
+ const img = await loadImage(imgSrc)
+ // Replicate CSS background-size: contain (preserve aspect ratio)
+ const imgAspect = img.naturalWidth / img.naturalHeight
+ let drawW = size
+ let drawH = size
+ if (imgAspect > 1) {
+ drawH = size / imgAspect
+ } else {
+ drawW = size * imgAspect
+ }
+ ctx.save()
+ ctx.translate(px, py)
+ ctx.rotate((rotation * Math.PI) / 180)
+ ctx.drawImage(img, -drawW / 2, -drawH / 2, drawW, drawH)
+ ctx.restore()
+ } catch (e) {
+ console.warn('[Export] Failed to load symbol image:', iconId, e)
+ }
+ }
+ }
+
+ // Draw arrowheads for arrow features
+ for (const f of currentFeatures.filter(f => f.type === 'arrow')) {
+ if (f.geometry.type !== 'LineString') continue
+ const lineCoords = f.geometry.coordinates as number[][]
+ if (lineCoords.length < 2) continue
+ const p1 = lineCoords[lineCoords.length - 2]
+ const p2 = lineCoords[lineCoords.length - 1]
+ const px1 = mapInstance.project(p1 as [number, number])
+ const px2 = mapInstance.project(p2 as [number, number])
+ const angle = Math.atan2(px2.y - px1.y, px2.x - px1.x)
+ const color = (f.properties.color as string) || '#000000'
+ const arrowSize = 14 * dpr
+
+ ctx.save()
+ ctx.translate(px2.x * dpr, px2.y * dpr)
+ ctx.rotate(angle + Math.PI / 2)
+ ctx.beginPath()
+ ctx.moveTo(0, -arrowSize)
+ ctx.lineTo(-arrowSize * 0.7, arrowSize * 0.3)
+ ctx.lineTo(arrowSize * 0.7, arrowSize * 0.3)
+ ctx.closePath()
+ ctx.fillStyle = color
+ ctx.fill()
+ ctx.restore()
+ }
+
+ // Draw line/polygon label markers at midpoints
+ for (const f of currentFeatures.filter(f => f.properties.label && (f.geometry.type === 'LineString' || f.geometry.type === 'Polygon'))) {
+ const label = f.properties.label as string
+ let midpoint: [number, number]
+
+ if (f.geometry.type === 'LineString') {
+ const coords = f.geometry.coordinates as number[][]
+ const midIdx = Math.floor(coords.length / 2)
+ if (coords.length === 2) {
+ midpoint = [(coords[0][0] + coords[1][0]) / 2, (coords[0][1] + coords[1][1]) / 2]
+ } else {
+ midpoint = coords[midIdx] as [number, number]
+ }
+ } else {
+ // Polygon: centroid of first ring
+ const ring = (f.geometry.coordinates as number[][][])[0]
+ const len = ring.length - 1
+ let cx = 0, cy = 0
+ for (let i = 0; i < len; i++) { cx += ring[i][0]; cy += ring[i][1] }
+ midpoint = [cx / len, cy / len]
+ }
+
+ const pixel = mapInstance.project(midpoint)
+ const px = pixel.x * dpr
+ const py = pixel.y * dpr
+ const fontSize = 13 * dpr
+ const isDanger = f.type === 'dangerzone'
+ const bgColor = isDanger ? 'rgba(220,38,38,0.85)' : 'rgba(0,0,0,0.75)'
+ const borderColor = isDanger ? '#dc2626' : 'rgba(255,255,255,0.5)'
+
+ ctx.save()
+ ctx.font = `bold ${fontSize}px system-ui, sans-serif`
+ ctx.textAlign = 'center'
+ ctx.textBaseline = 'middle'
+ const metrics = ctx.measureText(label)
+ const padX = 7 * dpr
+ const padY = 3 * dpr
+ const boxW = metrics.width + padX * 2
+ const boxH = fontSize + padY * 2
+ const radius = 4 * dpr
+
+ // Background pill
+ ctx.fillStyle = bgColor
+ ctx.beginPath()
+ ctx.roundRect(px - boxW / 2, py - boxH / 2, boxW, boxH, radius)
+ ctx.fill()
+
+ // Border
+ ctx.strokeStyle = borderColor
+ ctx.lineWidth = 1.5 * dpr
+ ctx.beginPath()
+ ctx.roundRect(px - boxW / 2, py - boxH / 2, boxW, boxH, radius)
+ ctx.stroke()
+
+ // Text
+ ctx.fillStyle = '#ffffff'
+ ctx.fillText(label, px, py)
+ ctx.restore()
+ }
+
+ // Draw text features
+ for (const f of currentFeatures.filter(f => f.type === 'text')) {
+ if (f.geometry.type !== 'Point') continue
+ const coords = f.geometry.coordinates as [number, number]
+ const pixel = mapInstance.project(coords)
+ const px = pixel.x * dpr
+ const py = pixel.y * dpr
+
+ const text = (f.properties.text as string) || ''
+ const fontSize = ((f.properties.fontSize as number) || 14) * dpr
+ const color = (f.properties.color as string) || '#000000'
+
+ ctx.save()
+ ctx.font = `bold ${fontSize}px system-ui, sans-serif`
+ ctx.textAlign = 'center'
+ ctx.textBaseline = 'middle'
+ // White outline
+ ctx.strokeStyle = '#ffffff'
+ ctx.lineWidth = 3 * dpr
+ ctx.lineJoin = 'round'
+ ctx.strokeText(text, px, py)
+ // Fill
+ ctx.fillStyle = color
+ ctx.fillText(text, px, py)
+ ctx.restore()
+ }
+
+ const title = currentProject?.title || 'Lageplan'
+ const safeName = title.replace(/[^a-z0-9äöüÄÖÜß]/gi, '_')
+
+ if (format === 'png') {
+ const link = document.createElement('a')
+ link.download = `${safeName}.png`
+ link.href = exportCanvas.toDataURL('image/png')
+ link.click()
+ addAudit(`Export: ${safeName}.png`)
+ toast({ title: 'Exportiert', description: `${safeName}.png wurde heruntergeladen.` })
+ } else {
+ // PDF Export — rapport-style clean layout
+ const imgData = exportCanvas.toDataURL('image/png')
+ const now = new Date()
+ const dateStr = now.toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit', year: 'numeric' })
+ const timeStr = now.toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' })
+ const locationStr = currentProject?.location || ''
+ const einsatzNr = (currentProject as any)?.einsatzNr || ''
+ const tenantLabel = tenant?.name || ''
+
+ // A4 landscape (mm)
+ const pdf = new jsPDF('l', 'mm', 'a4')
+ const pageW = pdf.internal.pageSize.getWidth() // 297
+ const pageH = pdf.internal.pageSize.getHeight() // 210
+ const m = 10 // margin
+
+ // ── Header section ──
+ const headerY = m
+ pdf.setFontSize(18)
+ pdf.setFont('helvetica', 'bold')
+ pdf.setTextColor(26, 26, 26)
+ pdf.text('Einsatz-Lageplan', m, headerY + 6)
+
+ pdf.setFontSize(9)
+ pdf.setFont('helvetica', 'normal')
+ pdf.setTextColor(107, 114, 128) // gray-500
+ pdf.text(`${tenantLabel}${tenantLabel ? ' · ' : ''}${title}`, m, headerY + 12)
+
+ // Right side: Einsatz-Nr + date
+ pdf.setFontSize(14)
+ pdf.setFont('helvetica', 'bold')
+ pdf.setTextColor(185, 28, 28) // red-700
+ if (einsatzNr) {
+ const nrW = pdf.getTextWidth(einsatzNr)
+ pdf.text(einsatzNr, pageW - m - nrW, headerY + 6)
+ }
+ pdf.setFontSize(9)
+ pdf.setFont('helvetica', 'normal')
+ pdf.setTextColor(107, 114, 128)
+ const dateLabel = `${dateStr} · ${timeStr}`
+ const dlW = pdf.getTextWidth(dateLabel)
+ pdf.text(dateLabel, pageW - m - dlW, headerY + 12)
+
+ // Divider line + red accent
+ const divY = headerY + 15
+ pdf.setDrawColor(26, 26, 26)
+ pdf.setLineWidth(0.8)
+ pdf.line(m, divY, pageW - m, divY)
+ pdf.setFillColor(185, 28, 28)
+ pdf.rect(m, divY, (pageW - 2 * m) * 0.3, 1, 'F')
+
+ // ── Map image ──
+ const mapTop = divY + 3
+ const mapBottom = pageH - m - 12 // leave space for footer
+ const mapAreaW = pageW - 2 * m
+ const mapAreaH = mapBottom - mapTop
+
+ // Fit map image into area while preserving aspect ratio
+ const imgAspect = w / h
+ const areaAspect = mapAreaW / mapAreaH
+ let drawW = mapAreaW
+ let drawH = mapAreaH
+ if (imgAspect > areaAspect) {
+ drawH = mapAreaW / imgAspect
+ } else {
+ drawW = mapAreaH * imgAspect
+ }
+ const mapX = m + (mapAreaW - drawW) / 2
+ const mapY = mapTop + (mapAreaH - drawH) / 2
+
+ // Light border around map
+ pdf.setDrawColor(229, 231, 235)
+ pdf.setLineWidth(0.3)
+ pdf.rect(mapX, mapY, drawW, drawH)
+ pdf.addImage(imgData, 'PNG', mapX, mapY, drawW, drawH)
+
+ // ── Footer ──
+ const footerY = pageH - m - 4
+ pdf.setFontSize(7)
+ pdf.setFont('helvetica', 'normal')
+ pdf.setTextColor(156, 163, 175) // gray-400
+ pdf.text(`Erstellt: ${dateStr} ${timeStr}${locationStr ? ' · Standort: ' + locationStr : ''} · Projekt: ${title}`, m, footerY)
+ const footerR = 'app.lageplan.ch'
+ const frW = pdf.getTextWidth(footerR)
+ pdf.text(footerR, pageW - m - frW, footerY)
+
+ pdf.save(`${safeName}.pdf`)
+ addAudit(`Export: ${safeName}.pdf`)
+ toast({ title: 'Exportiert', description: `${safeName}.pdf wurde heruntergeladen.` })
+ }
+ } catch (error) {
+ console.error('Export error:', error)
+ toast({ title: 'Fehler', description: 'Export fehlgeschlagen.', variant: 'destructive' })
+ }
+ }, [currentProject, tenant, toast, addAudit, mapRef, featuresRef])
+
+ return { handleExport }
+}
diff --git a/src/hooks/use-offline-sync.ts b/src/hooks/use-offline-sync.ts
new file mode 100644
index 0000000..7271c0e
--- /dev/null
+++ b/src/hooks/use-offline-sync.ts
@@ -0,0 +1,57 @@
+import { useState, useEffect } from 'react'
+import { flushSyncQueue, getSyncQueue, isOnline as checkOnline } from '@/lib/offline-sync'
+
+interface UseOfflineSyncOptions {
+ toast: (opts: { title: string; description?: string; variant?: string }) => void
+}
+
+export function useOfflineSync({ toast }: UseOfflineSyncOptions) {
+ const [isOffline, setIsOffline] = useState(false)
+ const [syncQueueCount, setSyncQueueCount] = useState(0)
+
+ useEffect(() => {
+ setIsOffline(!checkOnline())
+ setSyncQueueCount(getSyncQueue().length)
+
+ const goOffline = () => {
+ setIsOffline(true)
+ toast({ title: 'Offline-Modus', description: 'Änderungen werden lokal gespeichert und beim Reconnect synchronisiert.' })
+ }
+ const goOnline = async () => {
+ setIsOffline(false)
+ const queue = getSyncQueue()
+ if (queue.length > 0) {
+ toast({ title: 'Verbindung wiederhergestellt', description: `${queue.length} Änderung(en) werden synchronisiert...` })
+ const result = await flushSyncQueue()
+ setSyncQueueCount(getSyncQueue().length)
+ if (result.success > 0) {
+ toast({ title: 'Synchronisiert', description: `${result.success} Änderung(en) erfolgreich gespeichert.` })
+ }
+ if (result.failed > 0) {
+ toast({ title: 'Sync-Fehler', description: `${result.failed} Änderung(en) konnten nicht gespeichert werden.`, variant: 'destructive' })
+ }
+ } else {
+ toast({ title: 'Wieder online' })
+ }
+ }
+
+ window.addEventListener('offline', goOffline)
+ window.addEventListener('online', goOnline)
+
+ // Listen for SW sync messages
+ if ('serviceWorker' in navigator) {
+ navigator.serviceWorker.addEventListener('message', (event) => {
+ if (event.data?.type === 'FLUSH_SYNC_QUEUE') {
+ flushSyncQueue().then(() => setSyncQueueCount(getSyncQueue().length))
+ }
+ })
+ }
+
+ return () => {
+ window.removeEventListener('offline', goOffline)
+ window.removeEventListener('online', goOnline)
+ }
+ }, [toast])
+
+ return { isOffline, syncQueueCount, setSyncQueueCount }
+}
diff --git a/src/hooks/use-realtime-sync.ts b/src/hooks/use-realtime-sync.ts
new file mode 100644
index 0000000..6fc9bc5
--- /dev/null
+++ b/src/hooks/use-realtime-sync.ts
@@ -0,0 +1,268 @@
+import { useState, useCallback, useEffect, useRef } from 'react'
+import { getSocket, setSocketRoom } from '@/lib/socket'
+import type { DrawFeature, Project } from '@/types'
+
+interface UseRealtimeSyncOptions {
+ currentProject: Project | null
+ user: { id: string; name: string; role: string } | null
+ featuresRef: React.MutableRefObject
+ setFeatures: (features: DrawFeature[] | ((prev: DrawFeature[]) => DrawFeature[])) => void
+ toast: (opts: { title: string; description?: string; variant?: string }) => void
+}
+
+export function useRealtimeSync({
+ currentProject,
+ user,
+ featuresRef,
+ setFeatures,
+ toast,
+}: UseRealtimeSyncOptions) {
+ // Live editing lock state
+ const [editingBy, setEditingBy] = useState<{ id: string; name: string; since: string } | null>(null)
+ const [isEditingByMe, setIsEditingByMe] = useState(false)
+ const [editingLoading, setEditingLoading] = useState(false)
+
+ // Unique session ID per browser tab (survives re-renders, not page reload)
+ const sessionIdRef = useRef('')
+ if (!sessionIdRef.current) {
+ sessionIdRef.current = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
+ }
+
+ // ─── Editing Lock: Check status + Heartbeat + Polling ─────────
+
+ const checkEditingStatus = useCallback(async (projectId: string) => {
+ try {
+ const res = await fetch(`/api/projects/${projectId}/editing?sessionId=${sessionIdRef.current}`)
+ if (!res.ok) return
+ const data = await res.json()
+ if (data.editing) {
+ setEditingBy(data.editingBy)
+ setIsEditingByMe(data.isMe)
+ } else {
+ setEditingBy(null)
+ setIsEditingByMe(false)
+ }
+ } catch (e) {
+ console.warn('[Editing] Status check failed:', e)
+ }
+ }, [])
+
+ // Check editing status when project changes
+ useEffect(() => {
+ if (!currentProject?.id) {
+ setEditingBy(null)
+ setIsEditingByMe(false)
+ return
+ }
+ checkEditingStatus(currentProject.id)
+ }, [currentProject?.id, checkEditingStatus])
+
+ // Heartbeat: keep lock alive every 30s while I'm editing
+ useEffect(() => {
+ if (!currentProject?.id || !isEditingByMe) return
+ const interval = setInterval(async () => {
+ try {
+ await fetch(`/api/projects/${currentProject.id}/editing`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ action: 'heartbeat', sessionId: sessionIdRef.current }),
+ })
+ } catch (e) {
+ console.warn('[Heartbeat] Failed:', e)
+ }
+ }, 30000)
+ return () => clearInterval(interval)
+ }, [currentProject?.id, isEditingByMe])
+
+ // Socket.io: real-time sync for features, editing status, journal
+ const socketRef = useRef(null)
+ const prevProjectIdRef = useRef(null)
+
+ // Throttled socket broadcast for near-real-time sync
+ const lastEmitRef = useRef(0)
+ const emitTimerRef = useRef | null>(null)
+ const currentProjectRef = useRef(currentProject)
+ useEffect(() => { currentProjectRef.current = currentProject }, [currentProject])
+
+ const isEditingByMeRef = useRef(false)
+ useEffect(() => { isEditingByMeRef.current = isEditingByMe }, [isEditingByMe])
+
+ const broadcastFeatures = useCallback((feats: DrawFeature[]) => {
+ const proj = currentProjectRef.current
+ if (!socketRef.current || !proj?.id || !isEditingByMeRef.current) return
+ const now = Date.now()
+ const emit = () => {
+ socketRef.current?.emit('features-updated', {
+ projectId: proj!.id,
+ features: feats,
+ })
+ lastEmitRef.current = Date.now()
+ }
+ // Throttle: emit at most every 800ms for snappier sync
+ if (now - lastEmitRef.current > 800) {
+ emit()
+ } else {
+ if (emitTimerRef.current) clearTimeout(emitTimerRef.current)
+ emitTimerRef.current = setTimeout(emit, 800 - (now - lastEmitRef.current))
+ }
+ }, [])
+
+ useEffect(() => {
+ if (!currentProject?.id) return
+
+ const socket = getSocket()
+ socketRef.current = socket
+
+ // Leave old room, join new room
+ if (prevProjectIdRef.current && prevProjectIdRef.current !== currentProject.id) {
+ socket.emit('leave-project', prevProjectIdRef.current)
+ }
+ socket.emit('join-project', currentProject.id)
+ setSocketRoom(currentProject.id)
+ prevProjectIdRef.current = currentProject.id
+
+ // Listen for features changes from other clients (only apply if NOT the editor)
+ const onFeaturesChanged = (data: { features: any[] }) => {
+ // Skip if I'm the one editing — my local state is the source of truth
+ if (isEditingByMeRef.current) {
+ console.log('[Socket.io] Ignoring features-changed (I am the editor)')
+ return
+ }
+ if (data.features && Array.isArray(data.features)) {
+ console.log('[Socket.io] Features updated from another client')
+ setFeatures(data.features)
+ }
+ }
+
+ // Listen for editing status changes from other clients
+ const onEditingStatus = (data: { editing: boolean; editingBy: any; sessionId: string }) => {
+ if (data.sessionId === sessionIdRef.current) return // ignore own events
+ if (data.editing && data.editingBy) {
+ setEditingBy(data.editingBy)
+ setIsEditingByMe(false)
+ } else {
+ setEditingBy(null)
+ setIsEditingByMe(false)
+ }
+ }
+
+ // Listen for journal changes — trigger a re-fetch in JournalView
+ const onJournalChanged = () => {
+ console.log('[Socket.io] Journal updated from another client')
+ window.dispatchEvent(new CustomEvent('journal-refresh'))
+ }
+
+ socket.on('features-changed', onFeaturesChanged)
+ socket.on('editing-status', onEditingStatus)
+ socket.on('journal-changed', onJournalChanged)
+
+ return () => {
+ socket.off('features-changed', onFeaturesChanged)
+ socket.off('editing-status', onEditingStatus)
+ socket.off('journal-changed', onJournalChanged)
+ }
+ }, [currentProject?.id, setFeatures])
+
+ // Fallback: check editing status on initial load and every 30s
+ useEffect(() => {
+ if (!currentProject?.id) return
+ checkEditingStatus(currentProject.id)
+ const interval = setInterval(() => checkEditingStatus(currentProject.id), 30000)
+ return () => clearInterval(interval)
+ }, [currentProject?.id, checkEditingStatus])
+
+ // Release lock on unmount / page close
+ useEffect(() => {
+ const release = () => {
+ if (currentProject?.id && isEditingByMe) {
+ const blob = new Blob([JSON.stringify({ action: 'stop', sessionId: sessionIdRef.current })], { type: 'application/json' })
+ navigator.sendBeacon(`/api/projects/${currentProject.id}/editing`, blob)
+ }
+ }
+ window.addEventListener('beforeunload', release)
+ return () => {
+ window.removeEventListener('beforeunload', release)
+ release()
+ }
+ }, [currentProject?.id, isEditingByMe])
+
+ const handleStartEditing = useCallback(async () => {
+ if (!currentProject?.id) return
+ setEditingLoading(true)
+ try {
+ const res = await fetch(`/api/projects/${currentProject.id}/editing`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ action: 'start', sessionId: sessionIdRef.current }),
+ })
+ if (!res.ok) {
+ const data = await res.json()
+ toast({ title: 'Gesperrt', description: data.error || 'Bearbeitung nicht möglich', variant: 'destructive' })
+ return
+ }
+ setIsEditingByMe(true)
+ const editingInfo = { id: user!.id, name: user!.name, since: new Date().toISOString() }
+ setEditingBy(editingInfo)
+ // Notify other clients
+ socketRef.current?.emit('editing-changed', {
+ projectId: currentProject.id,
+ editing: true,
+ editingBy: editingInfo,
+ sessionId: sessionIdRef.current,
+ })
+ toast({ title: 'Bearbeitung gestartet', description: 'Sie können jetzt zeichnen und Einträge erstellen.' })
+ } catch (e) {
+ toast({ title: 'Fehler', description: 'Konnte Bearbeitung nicht starten.', variant: 'destructive' })
+ } finally {
+ setEditingLoading(false)
+ }
+ }, [currentProject?.id, user, toast])
+
+ const handleStopEditing = useCallback(async () => {
+ if (!currentProject?.id) return
+ setEditingLoading(true)
+ try {
+ // Save features before releasing lock
+ const currentFeatures = featuresRef.current
+ await fetch(`/api/projects/${currentProject.id}/features`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ features: currentFeatures }),
+ })
+ // Release lock
+ await fetch(`/api/projects/${currentProject.id}/editing`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ action: 'stop', sessionId: sessionIdRef.current }),
+ })
+ setIsEditingByMe(false)
+ setEditingBy(null)
+ // Notify other clients: editing stopped + send final features
+ socketRef.current?.emit('editing-changed', {
+ projectId: currentProject.id,
+ editing: false,
+ editingBy: null,
+ sessionId: sessionIdRef.current,
+ })
+ socketRef.current?.emit('features-updated', {
+ projectId: currentProject.id,
+ features: currentFeatures,
+ })
+ toast({ title: 'Bearbeitung beendet', description: 'Änderungen gespeichert. Andere können jetzt bearbeiten.' })
+ } catch (e) {
+ toast({ title: 'Fehler', description: 'Konnte Bearbeitung nicht beenden.', variant: 'destructive' })
+ } finally {
+ setEditingLoading(false)
+ }
+ }, [currentProject?.id, toast, featuresRef])
+
+ return {
+ editingBy,
+ isEditingByMe,
+ editingLoading,
+ socketRef,
+ broadcastFeatures,
+ handleStartEditing,
+ handleStopEditing,
+ }
+}
diff --git a/src/lib/api.ts b/src/lib/api.ts
new file mode 100644
index 0000000..66afc90
--- /dev/null
+++ b/src/lib/api.ts
@@ -0,0 +1,83 @@
+/**
+ * Standardized API fetch wrapper with consistent error handling.
+ *
+ * Usage:
+ * const data = await apiFetch<{ projects: Project[] }>('/api/projects')
+ * const result = await apiFetch('/api/admin/settings', { method: 'PUT', body: JSON.stringify({ ... }) })
+ */
+
+export class ApiError extends Error {
+ status: number
+ data: any
+
+ constructor(message: string, status: number, data?: any) {
+ super(message)
+ this.name = 'ApiError'
+ this.status = status
+ this.data = data
+ }
+}
+
+interface ApiFetchOptions extends RequestInit {
+ /** If true, don't throw on non-2xx responses — return null instead */
+ silent?: boolean
+}
+
+/**
+ * Typed fetch wrapper that:
+ * - Automatically sets Content-Type for JSON bodies
+ * - Parses JSON responses
+ * - Throws ApiError with status code and server error message on failure
+ * - Supports silent mode for optional/non-critical requests
+ */
+export async function apiFetch(
+ url: string,
+ options: ApiFetchOptions = {}
+): Promise {
+ const { silent, ...fetchOptions } = options
+
+ // Auto-set Content-Type for JSON string bodies
+ if (
+ fetchOptions.body &&
+ typeof fetchOptions.body === 'string' &&
+ !fetchOptions.headers
+ ) {
+ fetchOptions.headers = { 'Content-Type': 'application/json' }
+ } else if (
+ fetchOptions.body &&
+ typeof fetchOptions.body === 'string' &&
+ fetchOptions.headers &&
+ !(fetchOptions.headers as Record)['Content-Type']
+ ) {
+ fetchOptions.headers = {
+ ...fetchOptions.headers,
+ 'Content-Type': 'application/json',
+ }
+ }
+
+ const res = await fetch(url, fetchOptions)
+
+ if (!res.ok) {
+ if (silent) return null as T
+
+ let errorData: any = null
+ let errorMessage = `HTTP ${res.status}`
+ try {
+ errorData = await res.json()
+ errorMessage = errorData?.error || errorData?.message || errorMessage
+ } catch {
+ // Response not JSON, use status text
+ errorMessage = res.statusText || errorMessage
+ }
+ throw new ApiError(errorMessage, res.status, errorData)
+ }
+
+ // Handle 204 No Content
+ if (res.status === 204) return null as T
+
+ try {
+ return await res.json()
+ } catch {
+ return null as T
+ }
+}
diff --git a/src/lib/socket.ts b/src/lib/socket.ts
index a0d32a1..967408e 100644
--- a/src/lib/socket.ts
+++ b/src/lib/socket.ts
@@ -5,6 +5,31 @@ import { io, Socket } from 'socket.io-client'
let socket: Socket | null = null
let currentRoom: string | null = null
+export type SocketStatus = 'connected' | 'disconnected' | 'reconnecting'
+type StatusListener = (status: SocketStatus) => void
+
+let currentStatus: SocketStatus = 'disconnected'
+const statusListeners = new Set()
+
+function setStatus(status: SocketStatus) {
+ if (status === currentStatus) return
+ currentStatus = status
+ statusListeners.forEach(fn => fn(status))
+}
+
+/** Subscribe to socket connection status changes. Returns an unsubscribe function. */
+export function onSocketStatus(listener: StatusListener): () => void {
+ statusListeners.add(listener)
+ // Immediately notify current status
+ listener(currentStatus)
+ return () => { statusListeners.delete(listener) }
+}
+
+/** Get current socket connection status */
+export function getSocketStatus(): SocketStatus {
+ return currentStatus
+}
+
export function getSocket(): Socket {
if (!socket) {
socket = io({
@@ -20,6 +45,7 @@ export function getSocket(): Socket {
})
socket.on('connect', () => {
console.log('[Socket.io] Connected:', socket?.id)
+ setStatus('connected')
// Re-join project room after reconnect
if (currentRoom) {
console.log('[Socket.io] Re-joining room:', currentRoom)
@@ -28,6 +54,7 @@ export function getSocket(): Socket {
})
socket.on('disconnect', (reason) => {
console.warn('[Socket.io] Disconnected:', reason)
+ setStatus('disconnected')
if (reason === 'io server disconnect') {
// Server disconnected us, need to manually reconnect
socket?.connect()
@@ -38,9 +65,11 @@ export function getSocket(): Socket {
})
socket.io.on('reconnect', (attempt) => {
console.log('[Socket.io] Reconnected after', attempt, 'attempts')
+ setStatus('connected')
})
socket.io.on('reconnect_attempt', (attempt) => {
console.log('[Socket.io] Reconnect attempt', attempt)
+ setStatus('reconnecting')
})
}
return socket
diff --git a/src/stores/project-store.ts b/src/stores/project-store.ts
new file mode 100644
index 0000000..43e0dc9
--- /dev/null
+++ b/src/stores/project-store.ts
@@ -0,0 +1,69 @@
+import { create } from 'zustand'
+import type { Project, Feature, JournalEntry } from '@/types'
+
+interface ProjectStore {
+ // Projekt-Daten
+ project: Project | null
+ features: Feature[] // Karten-Elemente (Symbole, Linien, Polygone)
+ journalEntries: JournalEntry[]
+
+ // Actions
+ setProject: (project: Project | null) => void
+ setFeatures: (features: Feature[]) => void
+ addFeature: (feature: Feature) => void
+ updateFeature: (id: string, updates: Partial) => void
+ removeFeature: (id: string) => void
+
+ setJournalEntries: (entries: JournalEntry[]) => void
+ addJournalEntry: (entry: JournalEntry) => void
+
+ // Realtime-Sync Actions (werden von Socket.io getriggert)
+ syncFeatures: (features: Feature[]) => void
+ syncJournalEntry: (entry: JournalEntry) => void
+}
+
+export const useProjectStore = create((set) => ({
+ project: null,
+ features: [],
+ journalEntries: [],
+
+ setProject: (project) => set({ project }),
+
+ setFeatures: (features) => set({ features }),
+
+ addFeature: (feature) => set((state) => ({
+ features: [...state.features, feature]
+ })),
+
+ updateFeature: (id, updates) => set((state) => ({
+ features: state.features.map(f =>
+ f.id === id || f.properties?.id === id
+ ? { ...f, properties: { ...f.properties, ...updates } }
+ : f
+ )
+ })),
+
+ removeFeature: (id) => set((state) => ({
+ features: state.features.filter(f => f.id !== id && f.properties?.id !== id)
+ })),
+
+ setJournalEntries: (entries) => set({ journalEntries: entries }),
+
+ addJournalEntry: (entry) => set((state) => ({
+ journalEntries: [...state.journalEntries, entry]
+ })),
+
+ syncFeatures: (features) => set({ features }),
+
+ syncJournalEntry: (entry) => set((state) => {
+ // Avoid duplicates
+ if (state.journalEntries.some(e => e.id === entry.id)) {
+ return {
+ journalEntries: state.journalEntries.map(e => e.id === entry.id ? entry : e)
+ }
+ }
+ return {
+ journalEntries: [...state.journalEntries, entry]
+ }
+ }),
+}))
diff --git a/src/stores/tool-store.ts b/src/stores/tool-store.ts
new file mode 100644
index 0000000..f826092
--- /dev/null
+++ b/src/stores/tool-store.ts
@@ -0,0 +1,39 @@
+import { create } from 'zustand'
+import type { DrawMode } from '@/types'
+
+export type LineType = 'solid' | 'dashed' | 'dotted'
+
+interface ToolStore {
+ activeTool: DrawMode | null
+ activeColor: string
+ lineType: LineType
+ lineWidth: number
+ selectedFeatureId: string | null
+
+ // Actions
+ setActiveTool: (tool: ToolStore['activeTool']) => void
+ setActiveColor: (color: string) => void
+ setLineType: (type: ToolStore['lineType']) => void
+ setLineWidth: (width: number) => void
+ selectFeature: (id: string | null) => void
+ resetTool: () => void
+}
+
+export const useToolStore = create((set) => ({
+ activeTool: 'select',
+ activeColor: '#ff0000', // Default Rot
+ lineType: 'solid',
+ lineWidth: 3,
+ selectedFeatureId: null,
+
+ setActiveTool: (tool) => set({ activeTool: tool }),
+ setActiveColor: (color) => set({ activeColor: color }),
+ setLineType: (type) => set({ lineType: type }),
+ setLineWidth: (width) => set({ lineWidth: width }),
+ selectFeature: (id) => set({ selectedFeatureId: id }),
+
+ resetTool: () => set({
+ activeTool: 'select',
+ selectedFeatureId: null
+ }),
+}))
diff --git a/src/stores/ui-store.ts b/src/stores/ui-store.ts
new file mode 100644
index 0000000..fafdf1e
--- /dev/null
+++ b/src/stores/ui-store.ts
@@ -0,0 +1,39 @@
+import { create } from 'zustand'
+
+export type SidebarTab = 'map' | 'journal'
+export type ConnectionStatus = 'connected' | 'reconnecting' | 'offline'
+
+interface UIStore {
+ sidebarOpen: boolean
+ sidebarTab: SidebarTab
+ activeModal: string | null
+ isEditing: boolean
+ connectionStatus: ConnectionStatus
+
+ // Actions
+ toggleSidebar: () => void
+ setSidebarOpen: (open: boolean) => void
+ setSidebarTab: (tab: SidebarTab) => void
+ openModal: (modal: string) => void
+ closeModal: () => void
+ setIsEditing: (editing: boolean) => void
+ setConnectionStatus: (status: ConnectionStatus) => void
+}
+
+export const useUIStore = create((set) => ({
+ sidebarOpen: true,
+ sidebarTab: 'map',
+ activeModal: null,
+ isEditing: false,
+ connectionStatus: 'offline',
+
+ toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
+ setSidebarOpen: (open) => set({ sidebarOpen: open }),
+ setSidebarTab: (tab) => set({ sidebarTab: tab }),
+
+ openModal: (modal) => set({ activeModal: modal }),
+ closeModal: () => set({ activeModal: null }),
+
+ setIsEditing: (editing) => set({ isEditing: editing }),
+ setConnectionStatus: (status) => set({ connectionStatus: status }),
+}))
diff --git a/src/types/index.ts b/src/types/index.ts
new file mode 100644
index 0000000..ac8081c
--- /dev/null
+++ b/src/types/index.ts
@@ -0,0 +1,70 @@
+export interface Project {
+ id: string
+ title: string
+ location?: string
+ description?: string
+ einsatzleiter?: string
+ journalfuehrer?: string
+ mapCenter: { lng: number; lat: number }
+ mapZoom: number
+ isLocked: boolean
+ editingById?: string | null
+ editingUserName?: string | null
+ editingStartedAt?: string | null
+ planImageKey?: string | null
+ planBounds?: { north: number; south: number; east: number; west: number } | null
+ createdAt: string
+ updatedAt: string
+}
+
+export interface DrawFeature {
+ id: string
+ type: string
+ geometry: {
+ type: string
+ coordinates: number[] | number[][] | number[][][]
+ }
+ properties: Record
+}
+
+// Mapbox Feature Types
+export type Feature = {
+ id?: string | number
+ type: 'Feature'
+ geometry: {
+ type: string
+ coordinates: any
+ }
+ properties: Record
+}
+
+export type DrawMode =
+ | 'select'
+ | 'point'
+ | 'linestring'
+ | 'polygon'
+ | 'rectangle'
+ | 'circle'
+ | 'freehand'
+ | 'text'
+ | 'arrow'
+ | 'measure'
+ | 'dangerzone'
+ | 'eraser'
+
+export interface JournalEntry {
+ id: string
+ type: 'TEXT' | 'IMAGE' | 'AUDIO' | 'SOMA' | 'DANGER'
+ content?: string
+ timestamp: string
+ userId?: string
+ userName?: string
+ userRole?: string
+ isDone?: boolean
+ fileUrl?: string
+ fileKey?: string
+ somaTemplateId?: string
+ somaChecked?: boolean
+ isCorrected?: boolean
+ correctionOfId?: string
+}