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:
113
src/components/admin/dictionary-tab.tsx
Normal file
113
src/components/admin/dictionary-tab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
450
src/components/admin/settings-tab.tsx
Normal file
450
src/components/admin/settings-tab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
147
src/components/admin/soma-tab.tsx
Normal file
147
src/components/admin/soma-tab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
150
src/components/admin/suggestions-tab.tsx
Normal file
150
src/components/admin/suggestions-tab.tsx
Normal 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 "Was..."-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. "Leitung aufbauen", "Leitung abbauen", "Lüfter in Stellung", 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>
|
||||
)
|
||||
}
|
||||
372
src/components/admin/symbol-manager.tsx
Normal file
372
src/components/admin/symbol-manager.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
58
src/components/error-boundary.tsx
Normal file
58
src/components/error-boundary.tsx
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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">×</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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
236
src/components/journal/rapport-dialog.tsx
Normal file
236
src/components/journal/rapport-dialog.tsx
Normal 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">×</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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user