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)
This commit is contained in:
Pepe Ziberi
2026-02-25 00:06:39 +01:00
parent 8ddeb7b377
commit 5917fa88ad
30 changed files with 3110 additions and 2120 deletions

View File

@@ -0,0 +1,113 @@
'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 } from 'lucide-react'
import { apiFetch, ApiError } from '@/lib/api'
interface DictWord {
id: string
word: string
scope: string
}
export function DictionaryTab() {
const { toast } = useToast()
const [globalDictWords, setGlobalDictWords] = useState<DictWord[]>([])
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 (
<div className="border rounded-lg p-6">
<h3 className="font-semibold text-lg mb-2 flex items-center gap-2">
<BookOpen className="w-5 h-5" />
Globales Wörterbuch
</h3>
<p className="text-sm text-muted-foreground mb-4">
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.
</p>
{/* Add new word */}
<div className="flex gap-2 mb-4">
<Input
placeholder="Neuer globaler Begriff, z.B. 'Leitung aufbauen'..."
value={newGlobalWord}
onChange={(e) => setNewGlobalWord(e.target.value)}
onKeyDown={async (e) => {
if (e.key === 'Enter' && newGlobalWord.trim()) handleAdd()
}}
className="flex-1"
disabled={dictLoading}
/>
<Button
onClick={handleAdd}
disabled={!newGlobalWord.trim() || dictLoading}
>
<Plus className="w-4 h-4 mr-2" />
Hinzufügen
</Button>
</div>
{/* List of global words */}
{globalDictWords.length === 0 ? (
<div className="text-center text-muted-foreground py-8 text-sm border-2 border-dashed rounded-lg">
Noch keine globalen Begriffe hinterlegt.
</div>
) : (
<div className="flex flex-wrap gap-2">
{globalDictWords.map((w) => (
<span key={w.id} className="inline-flex items-center gap-1 px-3 py-1.5 bg-green-50 dark:bg-green-950/30 text-green-700 dark:text-green-300 rounded-full text-sm border border-green-200 dark:border-green-800">
{w.word}
<button
onClick={async () => {
try {
await apiFetch(`/api/dictionary/${w.id}`, { method: 'DELETE' })
fetchGlobalDict()
toast({ title: 'Entfernt' })
} catch { toast({ title: 'Fehler', variant: 'destructive' }) }
}}
className="ml-1 hover:text-red-500 transition-colors"
>
<X className="w-3.5 h-3.5" />
</button>
</span>
))}
</div>
)}
<p className="text-xs text-muted-foreground mt-4">
{globalDictWords.length} globale(r) Begriff(e). Diese erscheinen bei allen Mandanten als Vorschläge im Journal.
</p>
</div>
)
}

View File

@@ -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<string | null>(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<string | null>(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<string | null>(null)
// Default Symbol Scale
const [defaultSymbolScale, setDefaultSymbolScale] = useState('1.5')
const [symbolScaleLoading, setSymbolScaleLoading] = useState(false)
const [symbolScaleStatus, setSymbolScaleStatus] = useState<string | null>(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 (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Contact Email */}
<div className="border rounded-lg p-6 md:col-span-2">
<h3 className="font-semibold text-lg mb-4 flex items-center gap-2">
<Mail className="w-5 h-5 text-primary" />
Kontakt-E-Mail
</h3>
<p className="text-sm text-muted-foreground mb-3">E-Mail-Adresse für das Kontaktformular auf der Landing Page. Hierhin werden Anfragen gesendet.</p>
<div className="flex gap-2 items-end">
<div className="flex-1"><Label>Empfänger-Adresse</Label><Input value={contactEmail} onChange={e => setContactEmail(e.target.value)} placeholder="app@lageplan.ch" /></div>
<Button onClick={handleContactEmailSave} disabled={smtpLoading}>Speichern</Button>
</div>
</div>
{/* Registration Notification */}
<div className="border rounded-lg p-6">
<h3 className="font-semibold text-lg mb-4 flex items-center gap-2">
<Mail className="w-5 h-5 text-primary" />
Registrierungs-Benachrichtigung
</h3>
<p className="text-sm text-muted-foreground mb-3">E-Mail-Adresse, an die bei neuen Registrierungen eine Benachrichtigung gesendet wird. Leer lassen = keine Benachrichtigung.</p>
<div className="flex gap-2 items-end">
<div className="flex-1"><Label>Admin-E-Mail</Label><Input value={notifyRegistrationEmail} onChange={e => setNotifyRegistrationEmail(e.target.value)} placeholder="admin@lageplan.ch" /></div>
<Button onClick={async () => {
try {
const res = await fetch('/api/admin/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'save_setting', key: 'notify_registration_email', value: notifyRegistrationEmail }),
})
if ((await res.json()).success) toast({ title: 'Gespeichert' })
} catch { toast({ title: 'Fehler', variant: 'destructive' }) }
}} disabled={smtpLoading}>Speichern</Button>
</div>
</div>
{/* SMTP Settings */}
<div className="border rounded-lg p-6 md:col-span-2">
<h3 className="font-semibold text-lg mb-4 flex items-center gap-2">
<Mail className="w-5 h-5 text-primary" />
E-Mail / SMTP Konfiguration
</h3>
<p className="text-sm text-muted-foreground mb-4">SMTP-Server für den E-Mail-Versand konfigurieren. Empfohlen: TLS auf Port 587. Passwörter werden verschlüsselt in der Datenbank gespeichert.</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div><Label>SMTP Host</Label><Input value={smtpHost} onChange={e => setSmtpHost(e.target.value)} placeholder="smtp.gmail.com" /></div>
<div className="grid grid-cols-2 gap-2">
<div><Label>Port</Label><Input value={smtpPort} onChange={e => setSmtpPort(e.target.value)} placeholder="587" /></div>
<div className="flex items-end gap-2 pb-0.5">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input type="checkbox" checked={smtpSecure} onChange={e => setSmtpSecure(e.target.checked)} className="rounded" />
SSL/TLS
</label>
</div>
</div>
<div><Label>Benutzername</Label><Input value={smtpUser} onChange={e => setSmtpUser(e.target.value)} placeholder="user@example.com" /></div>
<div><Label>Passwort</Label><Input type="password" value={smtpPass} onChange={e => setSmtpPass(e.target.value)} placeholder="App-Passwort oder SMTP-Passwort" /></div>
<div><Label>Absender-Name</Label><Input value={smtpFromName} onChange={e => setSmtpFromName(e.target.value)} placeholder="Lageplan" /></div>
<div><Label>Absender-E-Mail</Label><Input value={smtpFromEmail} onChange={e => setSmtpFromEmail(e.target.value)} placeholder="noreply@lageplan.ch" /></div>
</div>
<div className="flex gap-2 mt-4">
<Button onClick={handleSmtpSave} disabled={smtpLoading || !smtpHost || !smtpUser}>
{smtpLoading ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : null}
Speichern
</Button>
<Button variant="outline" onClick={handleSmtpTest} disabled={smtpLoading || !smtpHost}>
Verbindung testen
</Button>
{smtpStatus === 'connected' && <span className="flex items-center text-sm text-green-600"><CheckCircle className="w-4 h-4 mr-1" /> Verbunden</span>}
{smtpStatus === 'error' && <span className="flex items-center text-sm text-red-600"><Ban className="w-4 h-4 mr-1" /> Fehlgeschlagen</span>}
{smtpStatus === 'saved' && <span className="flex items-center text-sm text-green-600"><CheckCircle className="w-4 h-4 mr-1" /> Gespeichert</span>}
</div>
<div className="border-t mt-4 pt-4">
<Label className="text-sm font-medium">Test-E-Mail senden</Label>
<div className="flex gap-2 mt-1.5">
<Input value={smtpTestEmail} onChange={e => setSmtpTestEmail(e.target.value)} placeholder="empfaenger@example.com" className="max-w-xs" />
<Button variant="outline" onClick={handleSmtpSendTest} disabled={smtpLoading || !smtpTestEmail}>
<Send className="w-4 h-4 mr-1.5" />
Senden
</Button>
</div>
</div>
</div>
{/* Stripe Settings */}
<div className="border rounded-lg p-6 md:col-span-2">
<h3 className="font-semibold text-lg mb-4 flex items-center gap-2">
<CreditCard className="w-5 h-5 text-primary" />
Stripe / Spenden-Konfiguration
</h3>
<p className="text-sm text-muted-foreground mb-4">
Stripe API-Keys für die Spendenseite konfigurieren. Unterstützt Kreditkarte, Twint und weitere Zahlungsmethoden.
Keys findest du im <a href="https://dashboard.stripe.com/apikeys" target="_blank" rel="noopener noreferrer" className="text-primary underline">Stripe Dashboard</a>.
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div><Label>Publishable Key (pk_...)</Label><Input value={stripePublicKey} onChange={e => setStripePublicKey(e.target.value)} placeholder="pk_live_..." /></div>
<div><Label>Secret Key (sk_...)</Label><Input type="password" value={stripeSecretKey} onChange={e => setStripeSecretKey(e.target.value)} placeholder="sk_live_..." /></div>
<div className="md:col-span-2"><Label>Webhook Secret (whsec_...) optional</Label><Input type="password" value={stripeWebhookSecret} onChange={e => setStripeWebhookSecret(e.target.value)} placeholder="whsec_..." /></div>
</div>
<p className="text-xs text-muted-foreground mt-2">
Webhook-Endpoint: <code className="bg-muted px-1.5 py-0.5 rounded text-xs">{typeof window !== 'undefined' ? window.location.origin : ''}/api/donate/webhook</code>
</p>
<div className="flex gap-2 mt-4">
<Button onClick={handleStripeSave} disabled={stripeLoading || !stripePublicKey || !stripeSecretKey}>
{stripeLoading ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : null}
Speichern
</Button>
{stripeStatus === 'saved' && <span className="flex items-center text-sm text-green-600"><CheckCircle className="w-4 h-4 mr-1" /> Gespeichert</span>}
{stripeStatus === 'error' && <span className="flex items-center text-sm text-red-600"><Ban className="w-4 h-4 mr-1" /> Fehlgeschlagen</span>}
</div>
</div>
{/* Demo Project */}
<div className="border rounded-lg p-6 md:col-span-2">
<h3 className="font-semibold text-lg mb-4 flex items-center gap-2">
<Map className="w-5 h-5 text-primary" />
Live-Demo auf der Startseite
</h3>
<p className="text-sm text-muted-foreground mb-3">
Wähle ein Projekt als Demo-Karte für die Landing Page. Besucher können die Karte sehen und zoomen, aber nichts bearbeiten.
</p>
<div className="flex gap-2 items-end">
<div className="flex-1">
<Label>Demo-Projekt</Label>
<select
value={demoProjectId}
onChange={e => setDemoProjectId(e.target.value)}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<option value=""> Keine Demo </option>
{allProjects.map(p => (
<option key={p.id} value={p.id}>{p.title}{p.location ? ` (${p.location})` : ''}</option>
))}
</select>
</div>
<Button
onClick={async () => {
setDemoLoading(true)
setDemoStatus(null)
try {
const res = await fetch('/api/admin/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'save_demo_project', demoProjectId }),
})
const data = await res.json()
if (data.success) {
toast({ title: 'Demo-Projekt gespeichert' })
setDemoStatus('saved')
} else throw new Error(data.error)
} catch (error) {
toast({ title: 'Fehler', description: error instanceof Error ? error.message : 'Fehler', variant: 'destructive' })
setDemoStatus('error')
} finally { setDemoLoading(false) }
}}
disabled={demoLoading}
>
{demoLoading ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : null}
Speichern
</Button>
{demoStatus === 'saved' && <span className="flex items-center text-sm text-green-600"><CheckCircle className="w-4 h-4 mr-1" /> Gespeichert</span>}
</div>
{demoProjectId && (
<p className="text-xs text-muted-foreground mt-2">
Vorschau: <a href="/demo" target="_blank" rel="noopener noreferrer" className="text-primary underline">/demo</a>
</p>
)}
</div>
{/* Symbol-Grösse */}
<div className="border rounded-lg p-6">
<h3 className="font-semibold text-lg mb-4 flex items-center gap-2">
<Settings className="w-5 h-5 text-primary" />
Standard Symbol-Grösse
</h3>
<p className="text-sm text-muted-foreground mb-3">
Bestimmt die Standard-Grösse neuer Symbole auf der Karte. Kleinere Werte = kleinere Symbole.
</p>
<div className="flex items-center gap-4 mb-3">
<input
type="range"
min="0.5"
max="5"
step="0.1"
value={defaultSymbolScale}
onChange={e => setDefaultSymbolScale(e.target.value)}
className="flex-1"
/>
<span className="text-lg font-bold w-16 text-center">{defaultSymbolScale}x</span>
</div>
<div className="flex items-center gap-3 text-xs text-muted-foreground mb-4">
<span>0.5x (klein)</span>
<span className="flex-1" />
<span>5x (gross)</span>
</div>
<div className="flex items-center gap-3">
<Button
variant="default"
size="sm"
disabled={symbolScaleLoading}
onClick={async () => {
setSymbolScaleLoading(true)
setSymbolScaleStatus(null)
try {
const res = await fetch('/api/admin/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'save_setting', key: 'default_symbol_scale', value: defaultSymbolScale }),
})
const data = await res.json()
if (data.success) setSymbolScaleStatus('saved')
} catch {} finally { setSymbolScaleLoading(false) }
}}
>
{symbolScaleLoading ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : null}
Speichern
</Button>
{symbolScaleStatus === 'saved' && <span className="flex items-center text-sm text-green-600"><CheckCircle className="w-4 h-4 mr-1" /> Gespeichert</span>}
</div>
</div>
{/* App Info */}
<div className="border rounded-lg p-6">
<h3 className="font-semibold text-lg mb-4 flex items-center gap-2">
<MapPin className="w-5 h-5 text-primary" />
System-Info
</h3>
<div className="space-y-3 text-sm">
<div className="flex justify-between"><span className="text-muted-foreground">Version</span><span className="font-medium">1.0.0</span></div>
<div className="flex justify-between"><span className="text-muted-foreground">Framework</span><span className="font-medium">Next.js 14.1</span></div>
<div className="flex justify-between"><span className="text-muted-foreground">Datenbank</span><span className="font-medium">PostgreSQL 16</span></div>
<div className="flex justify-between"><span className="text-muted-foreground">Benutzer</span><span className="font-medium">{usersCount}</span></div>
<div className="flex justify-between"><span className="text-muted-foreground">Mandanten</span><span className="font-medium">{tenantsCount}</span></div>
<div className="flex justify-between"><span className="text-muted-foreground">Symbole</span><span className="font-medium">{iconsCount}</span></div>
</div>
</div>
{/* Quick Actions */}
<div className="border rounded-lg p-6">
<h3 className="font-semibold text-lg mb-4 flex items-center gap-2">
<Settings className="w-5 h-5 text-primary" />
Schnellaktionen
</h3>
<div className="space-y-3">
<Button variant="outline" className="w-full justify-start" onClick={() => onNavigateTab('tenants')}>
<Shield className="w-4 h-4 mr-2" />
Mandanten verwalten
</Button>
<Button variant="outline" className="w-full justify-start" onClick={() => onNavigateTab('users')}>
<UserPlus className="w-4 h-4 mr-2" />
Benutzer anlegen
</Button>
<Button variant="outline" className="w-full justify-start" asChild>
<Link href="/app">
<ArrowLeft className="w-4 h-4 mr-2" />
Zur Krokier-App
</Link>
</Button>
</div>
</div>
</div>
)
}

View File

@@ -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<SomaTemplate[]>([])
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 (
<div className="border rounded-lg p-6">
<h3 className="font-semibold text-lg mb-2 flex items-center gap-2">
<AlertTriangle className="w-5 h-5 text-red-600" />
SOMA-Checkliste verwalten
</h3>
<p className="text-sm text-muted-foreground mb-4">
Definiere die Sofortmassnahmen (SOMA), die bei jedem neuen Einsatz als Checkliste erscheinen.
Bestehende Einsätze werden nicht verändert.
</p>
{somaLoading ? (
<div className="flex items-center gap-2 py-4 text-muted-foreground">
<Loader2 className="w-4 h-4 animate-spin" /> Laden...
</div>
) : (
<>
{/* Template list */}
<div className="border rounded-lg divide-y">
{somaTemplates.length === 0 ? (
<div className="px-4 py-6 text-center text-muted-foreground text-sm">
Keine SOMA-Vorlagen definiert. Neue Einsätze starten ohne Checkliste.
</div>
) : somaTemplates.map((tpl, idx) => (
<div key={tpl.id} className={`flex items-center gap-3 px-4 py-2.5 ${!tpl.isActive ? 'opacity-50' : ''}`}>
<GripVertical className="w-4 h-4 text-muted-foreground/40 shrink-0" />
<span className="text-sm font-medium flex-1">{tpl.label}</span>
<span className="text-xs text-muted-foreground">#{idx + 1}</span>
<Button
variant="ghost"
size="sm"
className="h-7 px-2"
onClick={async () => {
try {
await fetch('/api/tenant/soma-templates', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ updates: [{ id: tpl.id, isActive: !tpl.isActive }] }),
})
fetchSomaTemplates()
} catch {}
}}
>
{tpl.isActive ? <Eye className="w-3.5 h-3.5" /> : <EyeOff className="w-3.5 h-3.5" />}
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-destructive hover:text-destructive"
onClick={async () => {
if (!confirm(`"${tpl.label}" wirklich löschen?`)) return
try {
await fetch('/api/tenant/soma-templates', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: tpl.id }),
})
fetchSomaTemplates()
toast({ title: 'SOMA-Vorlage gelöscht' })
} catch {}
}}
>
<Trash2 className="w-3.5 h-3.5" />
</Button>
</div>
))}
</div>
{/* Add new */}
<div className="flex gap-2 mt-4">
<Input
placeholder="Neue Sofortmassnahme..."
value={newSomaLabel}
onChange={e => setNewSomaLabel(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter' && newSomaLabel.trim()) {
e.preventDefault()
handleAdd()
}
}}
className="flex-1"
/>
<Button disabled={!newSomaLabel.trim()} onClick={handleAdd}>
<Plus className="w-4 h-4 mr-1" /> Hinzufügen
</Button>
</div>
<p className="text-xs text-muted-foreground mt-3">
{somaTemplates.filter(t => t.isActive).length} aktiv / {somaTemplates.length} gesamt
Nur aktive Vorlagen erscheinen bei neuen Einsätzen.
</p>
</>
)}
</div>
)
}

View File

@@ -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<string[]>([])
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 (
<div className="border rounded-lg p-6">
<h3 className="font-semibold text-lg mb-2 flex items-center gap-2">
<BookOpen className="w-5 h-5" />
Journal-Wörterliste
</h3>
<p className="text-sm text-muted-foreground mb-4">
Häufige Begriffe und Textbausteine, die beim Erfassen von Journal-Einträgen als Vorschläge erscheinen.
Wenn der Benutzer im &quot;Was...&quot;-Feld tippt, werden passende Begriffe vorgeschlagen.
</p>
{/* Add new suggestion */}
<div className="flex gap-2 mb-4">
<Input
placeholder="Neuer Begriff, z.B. 'Leitung aufbauen'..."
value={newSuggestion}
onChange={(e) => setNewSuggestion(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && newSuggestion.trim()) handleAdd()
}}
className="flex-1"
/>
<Button onClick={handleAdd} disabled={!newSuggestion.trim()}>
<Plus className="w-4 h-4 mr-2" />
Hinzufügen
</Button>
</div>
{/* List of suggestions */}
{journalSuggestions.length === 0 ? (
<div className="text-center text-muted-foreground py-8 text-sm border-2 border-dashed rounded-lg">
Noch keine Begriffe hinterlegt. Fügen Sie häufig verwendete Textbausteine hinzu,<br />
z.B. &quot;Leitung aufbauen&quot;, &quot;Leitung abbauen&quot;, &quot;Lüfter in Stellung&quot;, etc.
</div>
) : (
<div className="flex flex-wrap gap-2">
{journalSuggestions.map((s, i) => (
<span key={i} className="inline-flex items-center gap-1 px-3 py-1.5 bg-blue-50 dark:bg-blue-950/30 text-blue-700 dark:text-blue-300 rounded-full text-sm border border-blue-200 dark:border-blue-800">
{s}
<button
onClick={() => handleRemove(i)}
className="ml-1 hover:text-red-500 transition-colors"
>
<X className="w-3.5 h-3.5" />
</button>
</span>
))}
</div>
)}
<div className="flex items-center gap-2 mt-4 pt-4 border-t">
<p className="text-xs text-muted-foreground flex-1">
{journalSuggestions.length} Begriff(e) hinterlegt. Änderungen werden automatisch gespeichert.
</p>
<Button
variant="outline"
size="sm"
onClick={() => {
const blob = new Blob([journalSuggestions.join('\n')], { type: 'text/plain' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'woerterliste.txt'
a.click()
URL.revokeObjectURL(url)
toast({ title: 'Exportiert', description: `${journalSuggestions.length} Begriffe exportiert` })
}}
disabled={journalSuggestions.length === 0}
>
<Download className="w-4 h-4 mr-1" />
Export
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
const input = document.createElement('input')
input.type = 'file'
input.accept = '.txt,.csv'
input.onchange = async (e) => {
const file = (e.target as HTMLInputElement).files?.[0]
if (!file) return
const text = await file.text()
const words = text.split(/[\n\r,;]+/).map(w => w.trim()).filter(Boolean)
if (words.length === 0) { toast({ title: 'Keine Begriffe gefunden', variant: 'destructive' }); return }
const merged = Array.from(new Set([...journalSuggestions, ...words])).sort((a, b) => a.localeCompare(b, 'de'))
setJournalSuggestions(merged)
saveSuggestions(merged)
toast({ title: 'Importiert', description: `${words.length} Begriffe importiert (${merged.length} total)` })
}
input.click()
}}
>
<Upload className="w-4 h-4 mr-1" />
Import
</Button>
</div>
</div>
)
}

View File

@@ -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<LibraryIcon[]>([])
const [mySymbols, setMySymbols] = useState<MySymbol[]>([])
// Library UI state
const [librarySearch, setLibrarySearch] = useState('')
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set())
const [libraryCollapsed, setLibraryCollapsed] = useState(false)
// My Symbols UI state
const [editingId, setEditingId] = useState<string | null>(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<Record<string, LibraryIcon[]>>((acc, icon) => {
const key = icon.categoryName
if (!acc[key]) acc[key] = []
acc[key].push(icon)
return acc
}, {})
if (loading) {
return (
<div className="flex items-center gap-2 py-12 justify-center text-muted-foreground">
<Loader2 className="w-5 h-5 animate-spin" /> Symbole laden...
</div>
)
}
return (
<div className="space-y-6">
{/* ===== MEINE SYMBOLE (always on top, prominent) ===== */}
<div className="border-2 border-primary/20 rounded-lg">
<div className="flex items-center justify-between px-4 py-3 bg-primary/5 border-b border-primary/20">
<h3 className="font-semibold text-sm flex items-center gap-2">
<LayoutGrid className="w-4 h-4 text-primary" />
Meine Symbole
<span className="text-xs text-muted-foreground font-normal">({mySymbols.length})</span>
</h3>
</div>
{mySymbols.length === 0 ? (
<div className="p-8 text-center">
<ImageIcon className="w-10 h-10 mx-auto text-muted-foreground/40 mb-3" />
<p className="text-sm text-muted-foreground mb-1">Noch keine eigenen Symbole definiert.</p>
<p className="text-xs text-muted-foreground">
Füge Symbole aus der Bibliothek unten hinzu oder lade eigene SVGs hoch.
</p>
</div>
) : (
<div className="p-4">
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-3">
{mySymbols.map(sym => (
<div
key={sym.id}
className="group relative border rounded-lg p-2 transition-all hover:shadow-md hover:border-primary/30"
>
<div className="aspect-square flex items-center justify-center mb-1.5 bg-muted/50 rounded">
<img
src={`/api/icons/${sym.iconId}/image`}
alt={sym.name}
className="w-12 h-12 object-contain"
draggable={false}
/>
</div>
{/* Name / Edit */}
{editingId === sym.id ? (
<div className="flex gap-0.5">
<Input
value={editName}
onChange={e => 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
/>
<button onClick={() => renameSymbol(sym.id, editName)} className="text-green-600 hover:text-green-700">
<Check className="w-3.5 h-3.5" />
</button>
<button onClick={() => { setEditingId(null); setEditName('') }} className="text-muted-foreground hover:text-foreground">
<X className="w-3.5 h-3.5" />
</button>
</div>
) : (
<p
className="text-[11px] text-center truncate cursor-pointer hover:text-primary"
title={`${sym.name}${sym.customName ? ` (Basis: ${sym.baseName})` : ''} — Klick zum Umbenennen`}
onClick={() => { setEditingId(sym.id); setEditName(sym.name) }}
>
{sym.name}
</p>
)}
{/* Hover actions */}
<div className="absolute top-1 right-1 flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => { setEditingId(sym.id); setEditName(sym.name) }}
className="w-5 h-5 rounded bg-background/80 border flex items-center justify-center text-muted-foreground hover:text-primary"
title="Umbenennen"
>
<Pencil className="w-3 h-3" />
</button>
<button
onClick={() => removeSymbol(sym.id)}
className="w-5 h-5 rounded bg-background/80 border flex items-center justify-center text-muted-foreground hover:text-destructive"
title="Entfernen"
>
<Trash2 className="w-3 h-3" />
</button>
</div>
{/* Custom name badge */}
{sym.customName && (
<div className="absolute top-1 left-1">
<div className="w-2 h-2 rounded-full bg-primary" title="Eigener Name" />
</div>
)}
</div>
))}
</div>
</div>
)}
</div>
{/* ===== UPLOAD HINWEIS ===== */}
<div className="flex items-start gap-3 px-4 py-3 bg-blue-50 dark:bg-blue-950/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<Info className="w-4 h-4 text-blue-600 dark:text-blue-400 shrink-0 mt-0.5" />
<div className="text-xs text-blue-700 dark:text-blue-300">
<strong>Tipp:</strong> Eigene Symbole am besten als <strong>SVG</strong> hochladen diese werden in jeder Grösse scharf dargestellt.
PNG/JPEG sind auch möglich, können aber bei Vergrösserung unscharf werden.
</div>
</div>
{/* ===== STANDARD-BIBLIOTHEK (collapsible) ===== */}
<div className="border rounded-lg">
<button
className="w-full flex items-center justify-between px-4 py-3 hover:bg-muted/50 transition-colors"
onClick={() => setLibraryCollapsed(!libraryCollapsed)}
>
<h3 className="font-semibold text-sm flex items-center gap-2">
{libraryCollapsed ? <ChevronRight className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
Standard-Bibliothek
<span className="text-xs text-muted-foreground font-normal">({library.length} Symbole)</span>
</h3>
<span className="text-xs text-muted-foreground">
{libraryCollapsed ? 'Aufklappen' : 'Zuklappen'}
</span>
</button>
{!libraryCollapsed && (
<div className="border-t px-4 py-3 space-y-4">
{/* Search */}
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder="Symbole suchen..."
value={librarySearch}
onChange={e => setLibrarySearch(e.target.value)}
className="pl-9"
/>
</div>
{/* Categories */}
{Object.entries(libraryGrouped).sort(([a], [b]) => a.localeCompare(b)).map(([catName, icons]) => (
<div key={catName} className="border rounded-lg">
<button
className="w-full flex items-center justify-between px-3 py-2 hover:bg-muted/30 transition-colors text-sm"
onClick={() => toggleCategory(catName)}
>
<span className="font-medium flex items-center gap-2">
{expandedCategories.has(catName) ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronRight className="w-3.5 h-3.5" />}
{catName}
<span className="text-xs text-muted-foreground font-normal">({icons.length})</span>
</span>
<Button
size="sm"
variant="ghost"
className="h-6 px-2 text-xs"
onClick={(e) => {
e.stopPropagation()
// Add all icons from this category
icons.forEach(icon => addSymbol(icon.id))
}}
>
<Plus className="w-3 h-3 mr-1" /> Alle hinzufügen
</Button>
</button>
{expandedCategories.has(catName) && (
<div className="border-t px-3 py-3">
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 lg:grid-cols-8 xl:grid-cols-10 gap-2">
{icons.map(icon => {
const alreadyAdded = mySymbols.some(s => s.iconId === icon.id)
return (
<button
key={icon.id}
onClick={() => addSymbol(icon.id)}
className={`group relative border rounded-lg p-2 transition-all hover:shadow-sm hover:border-primary/40 ${
alreadyAdded ? 'bg-green-50 dark:bg-green-950/20 border-green-200 dark:border-green-800' : ''
}`}
title={`${icon.name} — Klick zum Hinzufügen`}
>
<div className="aspect-square flex items-center justify-center mb-1 bg-muted/30 rounded">
<img
src={`/api/icons/${icon.id}/image`}
alt={icon.name}
className="w-10 h-10 object-contain"
draggable={false}
/>
</div>
<p className="text-[10px] text-center truncate">{icon.name}</p>
{/* Add overlay */}
<div className="absolute inset-0 flex items-center justify-center bg-primary/10 opacity-0 group-hover:opacity-100 rounded-lg transition-opacity">
<Plus className="w-5 h-5 text-primary" />
</div>
{/* Already added indicator */}
{alreadyAdded && (
<div className="absolute top-1 right-1 w-3 h-3 rounded-full bg-green-500 flex items-center justify-center">
<Check className="w-2 h-2 text-white" />
</div>
)}
</button>
)
})}
</div>
</div>
)}
</div>
))}
{Object.keys(libraryGrouped).length === 0 && (
<div className="text-center text-muted-foreground py-6 text-sm">
{librarySearch ? 'Keine Symbole gefunden.' : 'Keine Standard-Symbole vorhanden.'}
</div>
)}
</div>
)}
</div>
</div>
)
}

View File

@@ -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<ErrorBoundaryProps, ErrorBoundaryState> {
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 (
<div className="flex flex-col items-center justify-center min-h-[200px] p-8 text-center">
<AlertTriangle className="w-10 h-10 text-destructive mb-4" />
<h3 className="text-lg font-semibold mb-2">Etwas ist schiefgelaufen</h3>
<p className="text-sm text-muted-foreground mb-4 max-w-md">
{this.state.error?.message || 'Ein unerwarteter Fehler ist aufgetreten.'}
</p>
<Button variant="outline" onClick={this.handleReset}>
<RotateCcw className="w-4 h-4 mr-2" />
Erneut versuchen
</Button>
</div>
)
}
return this.props.children
}
}

View File

@@ -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<HTMLDivElement>(null)
// Rapport creation
const [creatingRapport, setCreatingRapport] = useState(false)
const [lastRapportLink, setLastRapportLink] = useState<string | null>(null)
const [showRapportDialog, setShowRapportDialog] = useState(false)
const [rapportForm, setRapportForm] = useState<Record<string, any>>({})
@@ -895,210 +895,16 @@ export function JournalView({ projectId, projectTitle, projectLocation, einsatzl
</div>
{/* Rapport Dialog */}
{showRapportDialog && (
<div className="fixed inset-0 z-50 bg-black/50 flex items-start justify-center overflow-auto py-8 print:hidden">
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-2xl w-full max-w-2xl mx-4">
<div className="flex items-center justify-between p-4 border-b">
<h3 className="text-lg font-bold flex items-center gap-2">
<FileText className="w-5 h-5" />
Einsatzrapport erstellen
</h3>
<button onClick={() => setShowRapportDialog(false)} className="text-gray-400 hover:text-gray-600 text-xl leading-none">&times;</button>
</div>
<div className="p-4 space-y-4 max-h-[70vh] overflow-auto">
{/* Organisation */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs font-semibold text-gray-500 uppercase">Organisation</label>
<Input value={rapportForm.organisation || ''} onChange={e => setRapportForm(f => ({ ...f, organisation: e.target.value }))} />
</div>
<div>
<label className="text-xs font-semibold text-gray-500 uppercase">Abteilung</label>
<Input value={rapportForm.abteilung || ''} onChange={e => setRapportForm(f => ({ ...f, abteilung: e.target.value }))} />
</div>
</div>
{/* Einsatzdaten */}
<div className="grid grid-cols-4 gap-3">
<div>
<label className="text-xs font-semibold text-gray-500 uppercase">Datum</label>
<Input value={rapportForm.datum || ''} onChange={e => setRapportForm(f => ({ ...f, datum: e.target.value }))} />
</div>
<div>
<label className="text-xs font-semibold text-gray-500 uppercase">Uhrzeit</label>
<Input value={rapportForm.uhrzeit || ''} onChange={e => setRapportForm(f => ({ ...f, uhrzeit: e.target.value }))} />
</div>
<div>
<label className="text-xs font-semibold text-gray-500 uppercase">Einsatz-Nr.</label>
<Input value={rapportForm.einsatzNr || ''} onChange={e => setRapportForm(f => ({ ...f, einsatzNr: e.target.value }))} />
</div>
<div>
<label className="text-xs font-semibold text-gray-500 uppercase">Priorität</label>
<Input value={rapportForm.prioritaet || ''} onChange={e => setRapportForm(f => ({ ...f, prioritaet: e.target.value }))} placeholder="z.B. Hoch" />
</div>
</div>
{/* Ort */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs font-semibold text-gray-500 uppercase">Einsatzort / Adresse</label>
<Input value={rapportForm.einsatzort || ''} onChange={e => setRapportForm(f => ({ ...f, einsatzort: e.target.value }))} />
</div>
<div>
<label className="text-xs font-semibold text-gray-500 uppercase">Objekt / Gebäude</label>
<Input value={rapportForm.objekt || ''} onChange={e => setRapportForm(f => ({ ...f, objekt: e.target.value }))} />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs font-semibold text-gray-500 uppercase">Alarmierungsart</label>
<Input value={rapportForm.alarmierungsart || ''} onChange={e => setRapportForm(f => ({ ...f, alarmierungsart: e.target.value }))} />
</div>
</div>
<div>
<label className="text-xs font-semibold text-gray-500 uppercase">Stichwort / Meldebild</label>
<Input value={rapportForm.stichwort || ''} onChange={e => setRapportForm(f => ({ ...f, stichwort: e.target.value }))} />
</div>
{/* Zeitverlauf */}
<div>
<label className="text-xs font-semibold text-gray-500 uppercase mb-1 block">Zeitverlauf</label>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-[10px] text-gray-400">Alarm</label>
<Input type="time" className="text-sm h-8" value={rapportForm.zeitAlarm || ''} onChange={e => setRapportForm(f => ({ ...f, zeitAlarm: e.target.value }))} />
</div>
<div>
<label className="text-[10px] text-gray-400">Eintreffen</label>
<Input type="time" className="text-sm h-8" value={rapportForm.zeitEintreffen || ''} onChange={e => setRapportForm(f => ({ ...f, zeitEintreffen: e.target.value }))} />
</div>
</div>
</div>
{/* Lagebild */}
<div>
<label className="text-xs font-semibold text-gray-500 uppercase">Lage bei Eintreffen</label>
<textarea className="w-full border rounded-md px-3 py-2 text-sm min-h-[60px] resize-y" value={rapportForm.lageEintreffen || ''} onChange={e => setRapportForm(f => ({ ...f, lageEintreffen: e.target.value }))} />
</div>
{/* Massnahmen (read-only, from journal) */}
<div>
<label className="text-xs font-semibold text-gray-500 uppercase">Massnahmen (aus Journal)</label>
<div className="border rounded-md p-2 bg-gray-50 dark:bg-gray-800 text-sm max-h-32 overflow-auto">
{Array.isArray(rapportForm.massnahmen) && rapportForm.massnahmen.length > 0 ? (
rapportForm.massnahmen.map((m: string, i: number) => <div key={i} className="py-0.5"> {m}</div>)
) : <span className="text-gray-400">Keine Einträge</span>}
</div>
</div>
{/* Bemerkungen */}
<div>
<label className="text-xs font-semibold text-gray-500 uppercase">Bemerkungen</label>
<textarea className="w-full border rounded-md px-3 py-2 text-sm min-h-[60px] resize-y" value={rapportForm.bemerkungen || ''} onChange={e => setRapportForm(f => ({ ...f, bemerkungen: e.target.value }))} />
</div>
{/* Unterschriften */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs font-semibold text-gray-500 uppercase">Einsatzleiter/in</label>
<Input value={rapportForm.einsatzleiter || ''} onChange={e => setRapportForm(f => ({ ...f, einsatzleiter: e.target.value }))} />
</div>
<div>
<label className="text-xs font-semibold text-gray-500 uppercase">Rapporteur</label>
<Input value={rapportForm.rapporteur || ''} onChange={e => setRapportForm(f => ({ ...f, rapporteur: e.target.value }))} />
</div>
</div>
</div>
<div className="flex items-center justify-end gap-3 p-4 border-t">
<Button variant="outline" size="sm" onClick={() => setShowRapportDialog(false)}>Abbrechen</Button>
<Button
size="sm"
disabled={creatingRapport}
onClick={async () => {
if (!projectId) return
setCreatingRapport(true)
try {
// Capture map screenshot — compress to JPEG and resize for smaller payload
let mapScreenshot = ''
const rawScreenshot = preCapuredScreenshot || ''
if (!rawScreenshot) {
try {
if (mapRef?.current) {
const canvas = mapRef.current.getCanvas()
if (canvas) {
// Resize to max 2400px wide and convert to JPEG
const maxW = 2400
const ratio = Math.min(1, maxW / canvas.width)
const offscreen = document.createElement('canvas')
offscreen.width = Math.round(canvas.width * ratio)
offscreen.height = Math.round(canvas.height * ratio)
const ctx = offscreen.getContext('2d')
if (ctx) {
ctx.drawImage(canvas, 0, 0, offscreen.width, offscreen.height)
mapScreenshot = offscreen.toDataURL('image/jpeg', 0.85)
}
}
}
} catch (e) { console.warn('Map screenshot failed:', e) }
} else if (rawScreenshot.length > 800000) {
// Compress pre-captured screenshot if too large
try {
const img = new Image()
img.src = rawScreenshot
await new Promise(r => { img.onload = r; img.onerror = r })
const maxW = 2400
const ratio = Math.min(1, maxW / img.naturalWidth)
const offscreen = document.createElement('canvas')
offscreen.width = Math.round(img.naturalWidth * ratio)
offscreen.height = Math.round(img.naturalHeight * ratio)
const ctx = offscreen.getContext('2d')
if (ctx) {
ctx.drawImage(img, 0, 0, offscreen.width, offscreen.height)
mapScreenshot = offscreen.toDataURL('image/jpeg', 0.85)
}
} catch { mapScreenshot = rawScreenshot }
} else {
mapScreenshot = rawScreenshot
}
// Convert logo URL to base64 for PDF rendering
let logoDataUri = ''
if (rapportForm.logoUrl) {
try {
const logoRes = await fetch(rapportForm.logoUrl)
if (logoRes.ok) {
const blob = await logoRes.blob()
logoDataUri = await new Promise<string>((resolve) => {
const reader = new FileReader()
reader.onloadend = () => resolve(reader.result as string)
reader.readAsDataURL(blob)
})
}
} catch (e) { console.warn('Logo fetch failed:', e) }
}
const rapportData = { ...rapportForm, mapScreenshot, logoUrl: logoDataUri || rapportForm.logoUrl }
console.log('[Rapport] Sending request, body size ~', JSON.stringify({ projectId, data: rapportData }).length, 'bytes')
const res = await fetch('/api/rapports', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ projectId, data: rapportData }),
})
if (res.ok) {
const result = await res.json()
setLastRapportLink(`/rapport/${result.token}`)
setShowRapportDialog(false)
window.open(`/rapport/${result.token}`, '_blank')
} else {
const errData = await res.json().catch(() => ({ error: `HTTP ${res.status}` }))
console.error('[Rapport] API error:', res.status, errData)
alert(`Rapport-Fehler: ${errData.error || 'Unbekannter Fehler (Status ' + res.status + ')'}`)
}
} catch (err: any) {
console.error('Error creating rapport:', err)
alert('Rapport-Fehler: ' + (err?.message || 'Netzwerkfehler'))
} finally {
setCreatingRapport(false)
}
}}
>
{creatingRapport ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <FileText className="w-4 h-4 mr-1.5" />}
Rapport generieren
</Button>
</div>
</div>
</div>
{showRapportDialog && projectId && (
<RapportDialog
projectId={projectId}
rapportForm={rapportForm}
setRapportForm={setRapportForm}
mapRef={mapRef}
mapScreenshot={preCapuredScreenshot}
onClose={() => setShowRapportDialog(false)}
onRapportCreated={(link) => setLastRapportLink(link)}
/>
)}
</>
)

View File

@@ -0,0 +1,236 @@
'use client'
import { useState, MutableRefObject } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { FileText, Loader2 } from 'lucide-react'
interface RapportDialogProps {
projectId: string
rapportForm: Record<string, any>
setRapportForm: React.Dispatch<React.SetStateAction<Record<string, any>>>
mapRef?: MutableRefObject<any>
mapScreenshot?: string
onClose: () => void
onRapportCreated: (link: string) => void
}
export function RapportDialog({
projectId,
rapportForm,
setRapportForm,
mapRef,
mapScreenshot: preCapuredScreenshot,
onClose,
onRapportCreated,
}: RapportDialogProps) {
const [creatingRapport, setCreatingRapport] = useState(false)
const handleCreate = async () => {
if (!projectId) return
setCreatingRapport(true)
try {
// Capture map screenshot — compress to JPEG and resize for smaller payload
let mapScreenshot = ''
const rawScreenshot = preCapuredScreenshot || ''
if (!rawScreenshot) {
try {
if (mapRef?.current) {
const canvas = mapRef.current.getCanvas()
if (canvas) {
// Resize to max 2400px wide and convert to JPEG
const maxW = 2400
const ratio = Math.min(1, maxW / canvas.width)
const offscreen = document.createElement('canvas')
offscreen.width = Math.round(canvas.width * ratio)
offscreen.height = Math.round(canvas.height * ratio)
const ctx = offscreen.getContext('2d')
if (ctx) {
ctx.drawImage(canvas, 0, 0, offscreen.width, offscreen.height)
mapScreenshot = offscreen.toDataURL('image/jpeg', 0.85)
}
}
}
} catch (e) { console.warn('Map screenshot failed:', e) }
} else if (rawScreenshot.length > 800000) {
// Compress pre-captured screenshot if too large
try {
const img = new Image()
img.src = rawScreenshot
await new Promise(r => { img.onload = r; img.onerror = r })
const maxW = 2400
const ratio = Math.min(1, maxW / img.naturalWidth)
const offscreen = document.createElement('canvas')
offscreen.width = Math.round(img.naturalWidth * ratio)
offscreen.height = Math.round(img.naturalHeight * ratio)
const ctx = offscreen.getContext('2d')
if (ctx) {
ctx.drawImage(img, 0, 0, offscreen.width, offscreen.height)
mapScreenshot = offscreen.toDataURL('image/jpeg', 0.85)
}
} catch { mapScreenshot = rawScreenshot }
} else {
mapScreenshot = rawScreenshot
}
// Convert logo URL to base64 for PDF rendering
let logoDataUri = ''
if (rapportForm.logoUrl) {
try {
const logoRes = await fetch(rapportForm.logoUrl)
if (logoRes.ok) {
const blob = await logoRes.blob()
logoDataUri = await new Promise<string>((resolve) => {
const reader = new FileReader()
reader.onloadend = () => resolve(reader.result as string)
reader.readAsDataURL(blob)
})
}
} catch (e) { console.warn('Logo fetch failed:', e) }
}
const rapportData = { ...rapportForm, mapScreenshot, logoUrl: logoDataUri || rapportForm.logoUrl }
console.log('[Rapport] Sending request, body size ~', JSON.stringify({ projectId, data: rapportData }).length, 'bytes')
const res = await fetch('/api/rapports', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ projectId, data: rapportData }),
})
if (res.ok) {
const result = await res.json()
onRapportCreated(`/rapport/${result.token}`)
onClose()
window.open(`/rapport/${result.token}`, '_blank')
} else {
const errData = await res.json().catch(() => ({ error: `HTTP ${res.status}` }))
console.error('[Rapport] API error:', res.status, errData)
alert(`Rapport-Fehler: ${errData.error || 'Unbekannter Fehler (Status ' + res.status + ')'}`)
}
} catch (err: any) {
console.error('Error creating rapport:', err)
alert('Rapport-Fehler: ' + (err?.message || 'Netzwerkfehler'))
} finally {
setCreatingRapport(false)
}
}
return (
<div className="fixed inset-0 z-50 bg-black/50 flex items-start justify-center overflow-auto py-8 print:hidden">
<div className="bg-card rounded-lg shadow-2xl w-full max-w-2xl mx-4">
<div className="flex items-center justify-between p-4 border-b">
<h3 className="text-lg font-bold flex items-center gap-2">
<FileText className="w-5 h-5" />
Einsatzrapport erstellen
</h3>
<button onClick={onClose} className="text-muted-foreground hover:text-foreground text-xl leading-none">&times;</button>
</div>
<div className="p-4 space-y-4 max-h-[70vh] overflow-auto">
{/* Organisation */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs font-semibold text-muted-foreground uppercase">Organisation</label>
<Input value={rapportForm.organisation || ''} onChange={e => setRapportForm(f => ({ ...f, organisation: e.target.value }))} />
</div>
<div>
<label className="text-xs font-semibold text-muted-foreground uppercase">Abteilung</label>
<Input value={rapportForm.abteilung || ''} onChange={e => setRapportForm(f => ({ ...f, abteilung: e.target.value }))} />
</div>
</div>
{/* Einsatzdaten */}
<div className="grid grid-cols-4 gap-3">
<div>
<label className="text-xs font-semibold text-muted-foreground uppercase">Datum</label>
<Input value={rapportForm.datum || ''} onChange={e => setRapportForm(f => ({ ...f, datum: e.target.value }))} />
</div>
<div>
<label className="text-xs font-semibold text-muted-foreground uppercase">Uhrzeit</label>
<Input value={rapportForm.uhrzeit || ''} onChange={e => setRapportForm(f => ({ ...f, uhrzeit: e.target.value }))} />
</div>
<div>
<label className="text-xs font-semibold text-muted-foreground uppercase">Einsatz-Nr.</label>
<Input value={rapportForm.einsatzNr || ''} onChange={e => setRapportForm(f => ({ ...f, einsatzNr: e.target.value }))} />
</div>
<div>
<label className="text-xs font-semibold text-muted-foreground uppercase">Priorität</label>
<Input value={rapportForm.prioritaet || ''} onChange={e => setRapportForm(f => ({ ...f, prioritaet: e.target.value }))} placeholder="z.B. Hoch" />
</div>
</div>
{/* Ort */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs font-semibold text-muted-foreground uppercase">Einsatzort / Adresse</label>
<Input value={rapportForm.einsatzort || ''} onChange={e => setRapportForm(f => ({ ...f, einsatzort: e.target.value }))} />
</div>
<div>
<label className="text-xs font-semibold text-muted-foreground uppercase">Objekt / Gebäude</label>
<Input value={rapportForm.objekt || ''} onChange={e => setRapportForm(f => ({ ...f, objekt: e.target.value }))} />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs font-semibold text-muted-foreground uppercase">Alarmierungsart</label>
<Input value={rapportForm.alarmierungsart || ''} onChange={e => setRapportForm(f => ({ ...f, alarmierungsart: e.target.value }))} />
</div>
</div>
<div>
<label className="text-xs font-semibold text-muted-foreground uppercase">Stichwort / Meldebild</label>
<Input value={rapportForm.stichwort || ''} onChange={e => setRapportForm(f => ({ ...f, stichwort: e.target.value }))} />
</div>
{/* Zeitverlauf */}
<div>
<label className="text-xs font-semibold text-muted-foreground uppercase mb-1 block">Zeitverlauf</label>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-[10px] text-muted-foreground">Alarm</label>
<Input type="time" className="text-sm h-8" value={rapportForm.zeitAlarm || ''} onChange={e => setRapportForm(f => ({ ...f, zeitAlarm: e.target.value }))} />
</div>
<div>
<label className="text-[10px] text-muted-foreground">Eintreffen</label>
<Input type="time" className="text-sm h-8" value={rapportForm.zeitEintreffen || ''} onChange={e => setRapportForm(f => ({ ...f, zeitEintreffen: e.target.value }))} />
</div>
</div>
</div>
{/* Lagebild */}
<div>
<label className="text-xs font-semibold text-muted-foreground uppercase">Lage bei Eintreffen</label>
<textarea className="w-full border rounded-md px-3 py-2 text-sm min-h-[60px] resize-y" value={rapportForm.lageEintreffen || ''} onChange={e => setRapportForm(f => ({ ...f, lageEintreffen: e.target.value }))} />
</div>
{/* Massnahmen (read-only, from journal) */}
<div>
<label className="text-xs font-semibold text-muted-foreground uppercase">Massnahmen (aus Journal)</label>
<div className="border rounded-md p-2 bg-muted text-sm max-h-32 overflow-auto">
{Array.isArray(rapportForm.massnahmen) && rapportForm.massnahmen.length > 0 ? (
rapportForm.massnahmen.map((m: string, i: number) => <div key={i} className="py-0.5"> {m}</div>)
) : <span className="text-muted-foreground">Keine Einträge</span>}
</div>
</div>
{/* Bemerkungen */}
<div>
<label className="text-xs font-semibold text-muted-foreground uppercase">Bemerkungen</label>
<textarea className="w-full border rounded-md px-3 py-2 text-sm min-h-[60px] resize-y" value={rapportForm.bemerkungen || ''} onChange={e => setRapportForm(f => ({ ...f, bemerkungen: e.target.value }))} />
</div>
{/* Unterschriften */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs font-semibold text-muted-foreground uppercase">Einsatzleiter/in</label>
<Input value={rapportForm.einsatzleiter || ''} onChange={e => setRapportForm(f => ({ ...f, einsatzleiter: e.target.value }))} />
</div>
<div>
<label className="text-xs font-semibold text-muted-foreground uppercase">Rapporteur</label>
<Input value={rapportForm.rapporteur || ''} onChange={e => setRapportForm(f => ({ ...f, rapporteur: e.target.value }))} />
</div>
</div>
</div>
<div className="flex items-center justify-end gap-3 p-4 border-t">
<Button variant="outline" size="sm" onClick={onClose}>Abbrechen</Button>
<Button
size="sm"
disabled={creatingRapport}
onClick={handleCreate}
>
{creatingRapport ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <FileText className="w-4 h-4 mr-1.5" />}
Rapport generieren
</Button>
</div>
</div>
</div>
)
}

View File

@@ -126,9 +126,26 @@ export function RightSidebar({ onSymbolDrop, canEdit, isOpen, onToggle, activeTa
// Separate tenant-specific icons ("Eigene" category) from global library
const eigene = allCats.find(c => c.name === 'Eigene')
const globalCats = allCats.filter(c => c.name !== 'Eigene')
setTenantIcons(eigene?.symbols || [])
// Merge: mySymbols (custom collection) + legacy "Eigene" category uploads
const mySymbols: DisplaySymbol[] = (data.mySymbols || []).map((s: any) => ({
id: s.id,
name: s.name,
imageUrl: s.url || `/api/icons/${s.id}/image`,
}))
const legacyOwn = eigene?.symbols || []
// Deduplicate: mySymbols takes priority over legacy
const mySymbolIds = new Set(mySymbols.map(s => s.id))
const mergedTenant = [...mySymbols, ...legacyOwn.filter(s => !mySymbolIds.has(s.id))]
setTenantIcons(mergedTenant)
setCategories(globalCats)
if (globalCats.length > 0) setActiveCategory(globalCats[0].id)
// Auto-collapse library if tenant has own symbols
if (mergedTenant.length > 0) {
setShowLibrarySection(false)
}
}
} catch (err) {
console.error('Failed to load icons:', err)

View File

@@ -1472,21 +1472,27 @@ export function MapView({
midpoint = [cx / len, cy / len]
}
// Apply stored label offset if present
const labelOffset = f.properties.labelOffset as [number, number] | undefined
if (labelOffset) {
midpoint = [midpoint[0] + labelOffset[0], midpoint[1] + labelOffset[1]]
}
const el = document.createElement('div')
const isDanger = f.type === 'dangerzone'
el.style.cssText = `
background: ${isDanger ? 'rgba(220,38,38,0.85)' : 'rgba(0,0,0,0.75)'};
background: ${isDanger ? 'rgba(220,38,38,0.85)' : 'rgba(0,0,0,0.82)'};
color: #fff;
padding: 1px 5px;
border-radius: 3px;
font-size: 11px;
padding: 3px 8px;
border-radius: 4px;
font-size: 13px;
font-weight: 600;
white-space: nowrap;
pointer-events: ${canEdit ? 'auto' : 'none'};
letter-spacing: 0.3px;
border: 1px solid ${isDanger ? '#dc2626' : 'rgba(255,255,255,0.4)'};
box-shadow: 0 1px 3px rgba(0,0,0,0.25);
cursor: ${canEdit ? 'pointer' : 'default'};
box-shadow: 0 1px 4px rgba(0,0,0,0.3);
cursor: ${canEdit ? 'grab' : 'default'};
transform: translate(0,0);
will-change: transform;
`
@@ -1503,11 +1509,11 @@ export function MapView({
const labelLine = document.createElement('div')
labelLine.textContent = label
labelLine.style.cssText = 'font-size:11px;font-weight:600;line-height:1.2;'
labelLine.style.cssText = 'font-size:13px;font-weight:700;line-height:1.3;'
const infoLine = document.createElement('div')
infoLine.textContent = `${lenText} / ${hoseCount} Schl.`
infoLine.style.cssText = 'font-size:8px;opacity:0.8;line-height:1.2;font-weight:400;'
infoLine.style.cssText = 'font-size:10px;opacity:0.85;line-height:1.3;font-weight:500;'
el.appendChild(labelLine)
el.appendChild(infoLine)
@@ -1519,11 +1525,11 @@ export function MapView({
const labelLine = document.createElement('div')
labelLine.textContent = label
labelLine.style.cssText = 'font-size:11px;font-weight:600;line-height:1.2;'
labelLine.style.cssText = 'font-size:13px;font-weight:700;line-height:1.3;'
const infoLine = document.createElement('div')
infoLine.textContent = areaText
infoLine.style.cssText = 'font-size:8px;opacity:0.8;line-height:1.2;font-weight:400;'
infoLine.style.cssText = 'font-size:10px;opacity:0.85;line-height:1.3;font-weight:500;'
el.appendChild(labelLine)
el.appendChild(infoLine)
@@ -1539,9 +1545,41 @@ export function MapView({
})
}
const marker = new maplibregl.Marker({ element: el, anchor: 'center', rotationAlignment: 'viewport' })
const marker = new maplibregl.Marker({ element: el, anchor: 'center', draggable: canEdit, rotationAlignment: 'viewport' })
.setLngLat(midpoint)
.addTo(map.current)
// Save label position offset on drag end
if (canEdit) {
marker.on('dragend', () => {
const newPos = marker.getLngLat()
// Calculate midpoint without offset to get the base midpoint
let baseMid: [number, number]
const feat = featuresRef.current.find(feat => feat.id === f.id)
if (!feat) return
if (feat.geometry.type === 'LineString') {
const coords = feat.geometry.coordinates as number[][]
const midIdx = Math.floor(coords.length / 2)
if (coords.length === 2) {
baseMid = [(coords[0][0] + coords[1][0]) / 2, (coords[0][1] + coords[1][1]) / 2]
} else {
baseMid = coords[midIdx] as [number, number]
}
} else {
const ring = (feat.geometry.coordinates as number[][][])[0]
const len = ring.length - 1
let cx = 0, cy = 0
for (let i = 0; i < len; i++) { cx += ring[i][0]; cy += ring[i][1] }
baseMid = [cx / len, cy / len]
}
const offset: [number, number] = [newPos.lng - baseMid[0], newPos.lat - baseMid[1]]
const updated = featuresRef.current.map(pf =>
pf.id === f.id ? { ...pf, properties: { ...pf.properties, labelOffset: offset } } : pf
)
onFeaturesChangeRef.current(updated)
})
}
markersRef.current.push(marker)
})