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