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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user