From 5917fa88adb56585ccd4ed13874b48710ce244da Mon Sep 17 00:00:00 2001 From: Pepe Ziberi Date: Wed, 25 Feb 2026 00:06:39 +0100 Subject: [PATCH] v1.3.0: Refactoring Phase 3+4, Symbol-Verwaltung Redesign, Schlauch-Labels Fix - Refactoring: Error Boundaries, apiFetch Wrapper, Socket Status-Tracking - Refactoring: UI Kontrast (theme-aware colors), unused imports bereinigt - Symbol-Verwaltung: Neues Split-Panel (Meine Symbole + Bibliothek) - Symbol-Verwaltung: Umbenennen (TLF rot/blau), Duplikate erlaubt - Symbol-Verwaltung: Karten-Sidebar zeigt eigene Symbole bevorzugt - Schlauch-Labels: Groessere Schrift (13px/10px), verschiebbar (Drag) - Schema: TenantSymbol customName, sortOrder, unique constraint entfernt - Open Source Referenz entfernt (kostenloses Projekt) --- package-lock.json | 36 +- package.json | 3 +- prisma/schema.prisma | 10 +- src/app/admin/error.tsx | 35 + src/app/admin/page.tsx | 1048 +-------------------- src/app/api/icons/route.ts | 55 +- src/app/api/tenant/symbols/route.ts | 163 +++- src/app/app/error.tsx | 35 + src/app/app/page.tsx | 866 ++--------------- src/components/admin/dictionary-tab.tsx | 113 +++ src/components/admin/settings-tab.tsx | 450 +++++++++ src/components/admin/soma-tab.tsx | 147 +++ src/components/admin/suggestions-tab.tsx | 150 +++ src/components/admin/symbol-manager.tsx | 372 ++++++++ src/components/error-boundary.tsx | 58 ++ src/components/journal/journal-view.tsx | 216 +---- src/components/journal/rapport-dialog.tsx | 236 +++++ src/components/layout/right-sidebar.tsx | 19 +- src/components/map/map-view.tsx | 60 +- src/hooks/use-auto-save.ts | 101 ++ src/hooks/use-keyboard-shortcuts.ts | 74 ++ src/hooks/use-map-export.ts | 329 +++++++ src/hooks/use-offline-sync.ts | 57 ++ src/hooks/use-realtime-sync.ts | 268 ++++++ src/lib/api.ts | 83 ++ src/lib/socket.ts | 29 + src/stores/project-store.ts | 69 ++ src/stores/tool-store.ts | 39 + src/stores/ui-store.ts | 39 + src/types/index.ts | 70 ++ 30 files changed, 3110 insertions(+), 2120 deletions(-) create mode 100644 src/app/admin/error.tsx create mode 100644 src/app/app/error.tsx create mode 100644 src/components/admin/dictionary-tab.tsx create mode 100644 src/components/admin/settings-tab.tsx create mode 100644 src/components/admin/soma-tab.tsx create mode 100644 src/components/admin/suggestions-tab.tsx create mode 100644 src/components/admin/symbol-manager.tsx create mode 100644 src/components/error-boundary.tsx create mode 100644 src/components/journal/rapport-dialog.tsx create mode 100644 src/hooks/use-auto-save.ts create mode 100644 src/hooks/use-keyboard-shortcuts.ts create mode 100644 src/hooks/use-map-export.ts create mode 100644 src/hooks/use-offline-sync.ts create mode 100644 src/hooks/use-realtime-sync.ts create mode 100644 src/lib/api.ts create mode 100644 src/stores/project-store.ts create mode 100644 src/stores/tool-store.ts create mode 100644 src/stores/ui-store.ts create mode 100644 src/types/index.ts 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.'} +

+
+ + +
+
+ ) +} 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 */} -
-
- - -
- setSymbolSearch(e.target.value)} - className="w-full sm:w-64" - /> - - - {tenantSymbols.filter(s => s.isActive).length} aktiv / {tenantSymbols.length} gesamt - - -
- - {/* Bulk category action */} - {symbolCatFilter !== 'all' && ( -
- - -
- )} - - {/* Selection action bar */} - {selectedSymbolIds.size > 0 && ( -
- {selectedSymbolIds.size} ausgewählt - - - -
- )} - - {/* 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} -
-

{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" - /> - -
- - {/* 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} - - - ))} -
- )} - -
-

- {journalSuggestions.length} Begriff(e) hinterlegt. Änderungen werden automatisch gespeichert. -

- - -
-
+
{/* ===== 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} - /> - -
- - {/* List of global words */} - {globalDictWords.length === 0 ? ( -
- Noch keine globalen Begriffe hinterlegt. -
- ) : ( -
- {globalDictWords.map((w) => ( - - {w.word} - - - ))} -
- )} - -

- {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.

-
-
setContactEmail(e.target.value)} placeholder="app@lageplan.ch" />
- -
-
- - {/* Registration Notification */} -
-

- - Registrierungs-Benachrichtigung -

-

E-Mail-Adresse, an die bei neuen Registrierungen eine Benachrichtigung gesendet wird. Leer lassen = keine Benachrichtigung.

-
-
setNotifyRegistrationEmail(e.target.value)} placeholder="admin@lageplan.ch" />
- -
-
- - {/* 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.

-
-
setSmtpHost(e.target.value)} placeholder="smtp.gmail.com" />
-
-
setSmtpPort(e.target.value)} placeholder="587" />
-
- -
-
-
setSmtpUser(e.target.value)} placeholder="user@example.com" />
-
setSmtpPass(e.target.value)} placeholder="App-Passwort oder SMTP-Passwort" />
-
setSmtpFromName(e.target.value)} placeholder="Lageplan" />
-
setSmtpFromEmail(e.target.value)} placeholder="noreply@lageplan.ch" />
-
-
- - - {smtpStatus === 'connected' && Verbunden} - {smtpStatus === 'error' && Fehlgeschlagen} - {smtpStatus === 'saved' && Gespeichert} -
-
- -
- setSmtpTestEmail(e.target.value)} placeholder="empfaenger@example.com" className="max-w-xs" /> - -
-
-
- - {/* 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. -

-
-
setStripePublicKey(e.target.value)} placeholder="pk_live_..." />
-
setStripeSecretKey(e.target.value)} placeholder="sk_live_..." />
-
setStripeWebhookSecret(e.target.value)} placeholder="whsec_..." />
-
-

- Webhook-Endpoint: {typeof window !== 'undefined' ? window.location.origin : ''}/api/donate/webhook -

-
- - {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. -

-
-
- - -
- - {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) -
-
- - {symbolScaleStatus === 'saved' && Gespeichert} -
-
- - {/* App Info */} -
-

- - System-Info -

-
-
Version1.0.0
-
FrameworkNext.js 14.1
-
DatenbankPostgreSQL 16
-
Benutzer{users.length}
-
Mandanten{tenants.length}
-
Symbole{icons.length}
-
-
- - {/* Quick Actions */} -
-

- - Schnellaktionen -

-
- - - -
-
-
+
)} @@ -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} - - -
- ))} -
- - {/* 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" - /> - -
- -

- {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.'} +

+
+ + +
+
+ ) +} 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} + /> + +
+ + {/* List of global words */} + {globalDictWords.length === 0 ? ( +
+ Noch keine globalen Begriffe hinterlegt. +
+ ) : ( +
+ {globalDictWords.map((w) => ( + + {w.word} + + + ))} +
+ )} + +

+ {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.

+
+
setContactEmail(e.target.value)} placeholder="app@lageplan.ch" />
+ +
+
+ + {/* Registration Notification */} +
+

+ + Registrierungs-Benachrichtigung +

+

E-Mail-Adresse, an die bei neuen Registrierungen eine Benachrichtigung gesendet wird. Leer lassen = keine Benachrichtigung.

+
+
setNotifyRegistrationEmail(e.target.value)} placeholder="admin@lageplan.ch" />
+ +
+
+ + {/* 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.

+
+
setSmtpHost(e.target.value)} placeholder="smtp.gmail.com" />
+
+
setSmtpPort(e.target.value)} placeholder="587" />
+
+ +
+
+
setSmtpUser(e.target.value)} placeholder="user@example.com" />
+
setSmtpPass(e.target.value)} placeholder="App-Passwort oder SMTP-Passwort" />
+
setSmtpFromName(e.target.value)} placeholder="Lageplan" />
+
setSmtpFromEmail(e.target.value)} placeholder="noreply@lageplan.ch" />
+
+
+ + + {smtpStatus === 'connected' && Verbunden} + {smtpStatus === 'error' && Fehlgeschlagen} + {smtpStatus === 'saved' && Gespeichert} +
+
+ +
+ setSmtpTestEmail(e.target.value)} placeholder="empfaenger@example.com" className="max-w-xs" /> + +
+
+
+ + {/* 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. +

+
+
setStripePublicKey(e.target.value)} placeholder="pk_live_..." />
+
setStripeSecretKey(e.target.value)} placeholder="sk_live_..." />
+
setStripeWebhookSecret(e.target.value)} placeholder="whsec_..." />
+
+

+ Webhook-Endpoint: {typeof window !== 'undefined' ? window.location.origin : ''}/api/donate/webhook +

+
+ + {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. +

+
+
+ + +
+ + {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) +
+
+ + {symbolScaleStatus === 'saved' && Gespeichert} +
+
+ + {/* App Info */} +
+

+ + System-Info +

+
+
Version1.0.0
+
FrameworkNext.js 14.1
+
DatenbankPostgreSQL 16
+
Benutzer{usersCount}
+
Mandanten{tenantsCount}
+
Symbole{iconsCount}
+
+
+ + {/* Quick Actions */} +
+

+ + Schnellaktionen +

+
+ + + +
+
+
+ ) +} 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} + + +
+ ))} +
+ + {/* Add new */} +
+ setNewSomaLabel(e.target.value)} + onKeyDown={e => { + if (e.key === 'Enter' && newSomaLabel.trim()) { + e.preventDefault() + handleAdd() + } + }} + className="flex-1" + /> + +
+ +

+ {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" + /> + +
+ + {/* 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} + + + ))} +
+ )} + +
+

+ {journalSuggestions.length} Begriff(e) hinterlegt. Änderungen werden automatisch gespeichert. +

+ + +
+
+ ) +} 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 => ( +
+
+ {sym.name} +
+ + {/* 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 + /> + + +
+ ) : ( +

{ setEditingId(sym.id); setEditName(sym.name) }} + > + {sym.name} +

+ )} + + {/* Hover actions */} +
+ + +
+ + {/* 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) ===== */} +
+ + + {!libraryCollapsed && ( +
+ {/* Search */} +
+ + setLibrarySearch(e.target.value)} + className="pl-9" + /> +
+ + {/* Categories */} + {Object.entries(libraryGrouped).sort(([a], [b]) => a.localeCompare(b)).map(([catName, icons]) => ( +
+ + + + {expandedCategories.has(catName) && ( +
+
+ {icons.map(icon => { + const alreadyAdded = mySymbols.some(s => s.iconId === icon.id) + return ( + + ) + })} +
+
+ )} +
+ ))} + + {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.'} +

+ +
+ ) + } + + 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 -

- -
-
- {/* Organisation */} -
-
- - setRapportForm(f => ({ ...f, organisation: e.target.value }))} /> -
-
- - setRapportForm(f => ({ ...f, abteilung: e.target.value }))} /> -
-
- {/* Einsatzdaten */} -
-
- - setRapportForm(f => ({ ...f, datum: e.target.value }))} /> -
-
- - setRapportForm(f => ({ ...f, uhrzeit: e.target.value }))} /> -
-
- - setRapportForm(f => ({ ...f, einsatzNr: e.target.value }))} /> -
-
- - setRapportForm(f => ({ ...f, prioritaet: e.target.value }))} placeholder="z.B. Hoch" /> -
-
- {/* Ort */} -
-
- - setRapportForm(f => ({ ...f, einsatzort: e.target.value }))} /> -
-
- - setRapportForm(f => ({ ...f, objekt: e.target.value }))} /> -
-
-
-
- - setRapportForm(f => ({ ...f, alarmierungsart: e.target.value }))} /> -
-
-
- - setRapportForm(f => ({ ...f, stichwort: e.target.value }))} /> -
- {/* Zeitverlauf */} -
- -
-
- - setRapportForm(f => ({ ...f, zeitAlarm: e.target.value }))} /> -
-
- - setRapportForm(f => ({ ...f, zeitEintreffen: e.target.value }))} /> -
-
-
- {/* Lagebild */} -
- -