f30e7158-97a8-4eaa-b0e8-a28e8a54a3fe
This commit is contained in:
1 parent
9feb124da6
commit
e16d7a748a
24 files changed
+641
-12
No files matched your search
@@ -22,6 +22,7 @@ import { AccountingView } from './views/accounting'
|
||||
import { AuditView } from './views/audit'
|
||||
import { ApiView } from './views/api'
|
||||
import { SettingsView } from './views/settings'
|
||||
import { GlobalSettingsView } from './views/global-settings'
|
||||
import { SetupWizardView } from './views/setup-wizard'
|
||||
import { PortalShell } from './portal-shell'
|
||||
|
||||
@@ -67,6 +68,7 @@ export function AppShell() {
|
||||
{view === 'audit' && <AuditView />}
|
||||
{view === 'api' && <ApiView />}
|
||||
{view === 'settings' && <SettingsView />}
|
||||
{view === 'settings_global' && <GlobalSettingsView />}
|
||||
{view === 'setup' && <SetupWizardView />}
|
||||
</main>
|
||||
<Footer />
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
import { ShieldCheck, Server, Database } from 'lucide-react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { useI18n } from '@/hooks/use-i18n'
|
||||
|
||||
export function Footer() {
|
||||
const { t } = useI18n()
|
||||
const year = new Date().getFullYear()
|
||||
return (
|
||||
<footer className="mt-auto border-t border-border bg-background">
|
||||
@@ -18,11 +20,11 @@ export function Footer() {
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5">
|
||||
<Badge variant="outline" className="gap-1 font-normal">
|
||||
<ShieldCheck className="size-3" />
|
||||
GoBD / DSGVO
|
||||
{t('footer.compliance')}
|
||||
</Badge>
|
||||
<span className="flex items-center gap-1">
|
||||
<Server className="size-3" />
|
||||
self-hosted
|
||||
{t('footer.selfHosted')}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Database className="size-3" />
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
'use client'
|
||||
|
||||
import { useAos } from '@/store/aos'
|
||||
import { LOCALES, type Locale } from '@/lib/i18n'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Globe, Check } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function LocaleSwitcher() {
|
||||
const { locale, setLocale } = useAos()
|
||||
const current = LOCALES.find((l) => l.id === locale) ?? LOCALES[0]!
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="gap-1.5 px-2">
|
||||
<Globe className="size-3.5" />
|
||||
<span className="text-xs font-medium uppercase">{current.id}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-[140px]">
|
||||
{LOCALES.map((l) => (
|
||||
<DropdownMenuItem
|
||||
key={l.id}
|
||||
onClick={() => setLocale(l.id as Locale)}
|
||||
className={cn('flex items-center gap-2', locale === l.id && 'font-medium')}
|
||||
>
|
||||
<span>{l.flag}</span>
|
||||
<span className="flex-1">{l.label}</span>
|
||||
{locale === l.id && <Check className="size-3.5" />}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -21,8 +21,10 @@ import {
|
||||
BookOpen,
|
||||
Webhook,
|
||||
ExternalLink,
|
||||
SlidersHorizontal,
|
||||
} from 'lucide-react'
|
||||
import { useAos, type ViewId } from '@/store/aos'
|
||||
import { translate } from '@/lib/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
@@ -32,14 +34,14 @@ const NAV: {
|
||||
items: { id: ViewId; label: string; icon: typeof LayoutDashboard; permission: string }[]
|
||||
}[] = [
|
||||
{
|
||||
group: 'Übersicht',
|
||||
group: 'nav.overview',
|
||||
items: [
|
||||
{ id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard, permission: '*' },
|
||||
{ id: 'reporting', label: 'Reporting', icon: BarChart3, permission: '*' },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Vertrieb',
|
||||
group: 'nav.sales',
|
||||
items: [
|
||||
{ id: 'crm', label: 'CRM', icon: Users, permission: 'crm.company:read' },
|
||||
{ id: 'leads', label: 'Leads Finder', icon: Radar, permission: 'leads:read' },
|
||||
@@ -47,14 +49,14 @@ const NAV: {
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Lieferung',
|
||||
group: 'nav.delivery',
|
||||
items: [
|
||||
{ id: 'projects', label: 'Projekte', icon: FolderKanban, permission: 'projects:read' },
|
||||
{ id: 'support', label: 'Support', icon: LifeBuoy, permission: 'support:read' },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Betrieb',
|
||||
group: 'nav.operations',
|
||||
items: [
|
||||
{ id: 'licenses', label: 'Lizenzen', icon: KeyRound, permission: 'licenses:read' },
|
||||
{ id: 'contracts', label: 'Verträge', icon: FileSignature, permission: 'contracts:read' },
|
||||
@@ -65,9 +67,10 @@ const NAV: {
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'System',
|
||||
group: 'nav.system',
|
||||
items: [
|
||||
{ id: 'settings', label: 'Erweiterungen', icon: Settings2, permission: 'admin' },
|
||||
{ id: 'settings_global', label: 'Einstellungen', icon: SlidersHorizontal, permission: 'admin' },
|
||||
{ id: 'api', label: 'API & Webhooks', icon: Webhook, permission: 'admin' },
|
||||
{ id: 'setup', label: 'Setup-Wizard', icon: Rocket, permission: 'admin' },
|
||||
],
|
||||
@@ -75,7 +78,8 @@ const NAV: {
|
||||
]
|
||||
|
||||
export function SidebarNav() {
|
||||
const { view, setView, setSidebarOpen, setCommandOpen, setPortalMode } = useAos()
|
||||
const { view, setView, setSidebarOpen, setCommandOpen, setPortalMode, locale } = useAos()
|
||||
const t = (k: string) => translate(k, locale)
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-sidebar text-sidebar-foreground">
|
||||
@@ -97,7 +101,7 @@ export function SidebarNav() {
|
||||
className="flex w-full items-center gap-2 rounded-md border border-sidebar-border bg-background/50 px-3 py-2 text-sm text-muted-foreground transition hover:bg-background"
|
||||
>
|
||||
<Search className="size-3.5" />
|
||||
<span>Suchen…</span>
|
||||
<span>{t('common.search')}</span>
|
||||
<kbd className="ml-auto inline-flex items-center gap-0.5 rounded border border-border bg-muted px-1.5 py-0.5 text-[10px] font-mono">
|
||||
<Command className="size-2.5" />K
|
||||
</kbd>
|
||||
@@ -109,12 +113,13 @@ export function SidebarNav() {
|
||||
{NAV.map((section) => (
|
||||
<div key={section.group} className="mb-4">
|
||||
<p className="px-3 pb-1.5 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/70">
|
||||
{section.group}
|
||||
{t(section.group)}
|
||||
</p>
|
||||
<div className="space-y-0.5">
|
||||
{section.items.map((item) => {
|
||||
const Icon = item.icon
|
||||
const active = view === item.id
|
||||
const label = t('nav.' + item.id)
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
@@ -131,7 +136,7 @@ export function SidebarNav() {
|
||||
aria-current={active ? 'page' : undefined}
|
||||
>
|
||||
<Icon className={cn('size-4 shrink-0', active ? 'text-foreground' : 'text-muted-foreground group-hover:text-foreground')} />
|
||||
<span className="truncate">{item.label}</span>
|
||||
<span className="truncate">{label}</span>
|
||||
{item.id === 'leads' && (
|
||||
<Badge variant="secondary" className="ml-auto h-5 px-1.5 text-[10px]">
|
||||
AI
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { Menu, Search, Bell, ChevronRight } from 'lucide-react'
|
||||
import { useAos, type ViewId } from '@/store/aos'
|
||||
import { ThemeToggle } from '@/components/theme-toggle'
|
||||
import { LocaleSwitcher } from './locale-switcher'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
|
||||
@@ -31,6 +32,7 @@ const TITLES: Record<ViewId, { title: string; crumb: string }> = {
|
||||
audit: { title: 'Audit-Protokoll', crumb: 'Betrieb / Audit' },
|
||||
api: { title: 'API & Webhooks', crumb: 'System / API' },
|
||||
settings: { title: 'Erweiterungen', crumb: 'System / Module' },
|
||||
settings_global: { title: 'Einstellungen', crumb: 'System / Einstellungen' },
|
||||
setup: { title: 'Setup-Wizard', crumb: 'System / Setup' },
|
||||
}
|
||||
|
||||
@@ -74,6 +76,7 @@ export function TopBar() {
|
||||
</Button>
|
||||
|
||||
<ThemeToggle />
|
||||
<LocaleSwitcher />
|
||||
|
||||
<Button variant="ghost" size="icon" className="relative" aria-label="Benachrichtigungen">
|
||||
<Bell className="size-4" />
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useFetch } from '@/hooks/use-fetch'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { PageHeader } from '../primitives'
|
||||
import { useAos } from '@/store/aos'
|
||||
import { LOCALES } from '@/lib/i18n'
|
||||
import { Loader2, Save, Building2, FileText, Percent, Radar, FileCheck2, Languages, Lock, CheckCircle2 } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface SettingsData {
|
||||
settings: Record<string, string>
|
||||
}
|
||||
|
||||
export function GlobalSettingsView() {
|
||||
const { data, loading, refetch } = useFetch<SettingsData>('/api/aos/settings')
|
||||
const { locale, setLocale } = useAos()
|
||||
const [form, setForm] = useState<Record<string, string>>({})
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [dirty, setDirty] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.settings) {
|
||||
setForm(data.settings)
|
||||
setDirty(false)
|
||||
}
|
||||
}, [data])
|
||||
|
||||
if (loading || !data) {
|
||||
return (
|
||||
<div className="space-y-6 p-4 md:p-6">
|
||||
<Skeleton className="h-9 w-40" />
|
||||
<Skeleton className="h-96" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const update = (key: string, value: string) => {
|
||||
setForm((f) => ({ ...f, [key]: value }))
|
||||
setDirty(true)
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true)
|
||||
const t = toast.loading('Einstellungen werden gespeichert…')
|
||||
try {
|
||||
const r = await fetch('/api/aos/settings', {
|
||||
method: 'PATCH',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(form),
|
||||
})
|
||||
const res = await r.json()
|
||||
if (!r.ok) throw new Error(res.error || 'save failed')
|
||||
toast.success(`${res.updated} Einstellungen gespeichert (Audit protokolliert)`, { id: t })
|
||||
setDirty(false)
|
||||
refetch()
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Speichern fehlgeschlagen', { id: t })
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const Field = ({ label, k, mono, suffix }: { label: string; k: string; mono?: boolean; suffix?: string }) => (
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">{label}</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={form[k] ?? ''}
|
||||
onChange={(e) => update(k, e.target.value)}
|
||||
className={cn('text-sm', mono && 'font-mono')}
|
||||
/>
|
||||
{suffix && <span className="shrink-0 text-xs text-muted-foreground">{suffix}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-4 md:p-6">
|
||||
<PageHeader
|
||||
title="Einstellungen"
|
||||
description="Typisierte, datenbankgestützte Konfiguration. Alle Änderungen werden im Audit-Log protokolliert (Hash-Kette). Secrets werden AES-256-GCM verschlüsselt."
|
||||
actions={
|
||||
<Button onClick={save} disabled={saving || !dirty} className="gap-1.5">
|
||||
{saving ? <Loader2 className="size-4 animate-spin" /> : <Save className="size-4" />}
|
||||
Speichern
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* i18n / Locale */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
<Languages className="size-4 text-muted-foreground" />
|
||||
Sprache & Lokalisierung
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">UI-Sprache (PRD §5.5: DE default, EN, TR)</Label>
|
||||
<div className="flex gap-2">
|
||||
{LOCALES.map((l) => (
|
||||
<button
|
||||
key={l.id}
|
||||
onClick={() => setLocale(l.id)}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-md border px-3 py-2 text-xs transition',
|
||||
locale === l.id ? 'border-foreground/40 bg-foreground/5' : 'border-border hover:bg-muted/50'
|
||||
)}
|
||||
>
|
||||
<span>{l.flag}</span>
|
||||
<span className="font-medium">{l.label}</span>
|
||||
{locale === l.id && <CheckCircle2 className="size-3" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
<Field label="Standard-Sprache (neue Nutzer)" k="locale.default" />
|
||||
<Field label="Verfügbare Sprachen (komma-getrennt)" k="locale.available" />
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Zahlen-/Datumsformat folgt der UI-Sprache: DE = 1.234,56 € / 26.10.2026, EN = 1,234.56 € / 10/26/2026, TR = 1.234,56 ₺ / 26.10.2026
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Company data */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
<Building2 className="size-4 text-muted-foreground" />
|
||||
Unternehmens-Stammdaten
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<Field label="Firmenname" k="company.name" />
|
||||
<Field label="Rechtsform" k="company.legalForm" />
|
||||
<Field label="USt-IdNr." k="company.vatId" mono />
|
||||
<Field label="Registergericht" k="company.registerCourt" />
|
||||
<Field label="HRB-Nummer" k="company.registerNumber" mono />
|
||||
<Field label="Stadt" k="company.city" />
|
||||
<Field label="PLZ" k="company.zip" />
|
||||
<Field label="Straße" k="company.street" />
|
||||
<Field label="IBAN" k="company.bank" mono />
|
||||
<Field label="BIC" k="company.bic" mono />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Invoice numbering */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
<FileText className="size-4 text-muted-foreground" />
|
||||
Rechnungs-Nummernkreis (GoBD: lückenlos)
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<Field label="Präfix" k="invoice.numberPrefix" mono />
|
||||
<Field label="Jahr" k="invoice.numberYear" mono />
|
||||
<Field label="Nächste Nummer" k="invoice.numberNext" mono />
|
||||
</CardContent>
|
||||
<CardContent className="pt-0">
|
||||
<p className="rounded-md border border-dashed border-border p-2.5 text-[11px] text-muted-foreground">
|
||||
Vorschau: <code className="font-mono">{form['invoice.numberPrefix'] ?? 'RE'}-{form['invoice.numberYear'] ?? '2026'}-{String(form['invoice.numberNext'] ?? '0042').padStart(4, '0')}</code>
|
||||
— GoBD-konform, kein Löschen, Korrekturen als Storno (neue Zeile).
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Taxes */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
<Percent className="size-4 text-muted-foreground" />
|
||||
Steuersätze (Basispunkte)
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<Field label="Standard-Steuersatz" k="tax.standardBp" suffix="Bp (1900 = 19%)" />
|
||||
<Field label="Ermäßigter Steuersatz" k="tax.reducedBp" suffix="Bp (700 = 7%)" />
|
||||
<Field label="Reverse Charge (Default)" k="tax.reverseChargeDefault" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Lead Finder */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
<Radar className="size-4 text-muted-foreground" />
|
||||
Leads Finder
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<Field label="Such-Radius" k="leadFinder.radiusKm" suffix="km" />
|
||||
<Field label="Kosten-Limit pro Lauf" k="leadFinder.costCapEur" suffix="€" />
|
||||
<Field label="Auto-Genehmigung ab Score" k="leadFinder.autoApproveThreshold" suffix="/ 100" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* E-Rechnung */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
<FileCheck2 className="size-4 text-muted-foreground" />
|
||||
E-Rechnung (ZUGFeRD / XRechnung)
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Format</Label>
|
||||
<select
|
||||
value={form['erechnung.format'] ?? 'zugferd'}
|
||||
onChange={(e) => update('erechnung.format', e.target.value)}
|
||||
className="h-9 w-full rounded border border-input bg-background px-3 text-sm outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
>
|
||||
<option value="zugferd">ZUGFeRD (Comfort)</option>
|
||||
<option value="xrechnung">XRechnung (CII)</option>
|
||||
<option value="both">Beide (Kunde wählt)</option>
|
||||
</select>
|
||||
</div>
|
||||
<Field label="Leitweg-ID (öffentliche Kunden)" k="erechnung.leitwegId" mono />
|
||||
</CardContent>
|
||||
<CardContent className="pt-0">
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Empfangs-Pflicht seit 2025, Versand-Pflicht ab 2027/2028 (umsatzabhängig). Validierung via <code className="font-mono">@e-invoice-eu/core</code> (EN 16931).
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Compliance / Retention */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
<Lock className="size-4 text-muted-foreground" />
|
||||
Compliance & Aufbewahrung
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<Field label="Aufbewahrung Rechnungen" k="compliance.retentionInvoicesYears" suffix="Jahre (8)" />
|
||||
<Field label="Aufbewahrung Geschäftsbriefe" k="compliance.retentionBusinessYears" suffix="Jahre (6)" />
|
||||
<Field label="Encryption-Key Escrow" k="compliance.encryptionKeyEscrow" />
|
||||
</CardContent>
|
||||
<CardContent className="pt-0">
|
||||
<div className="rounded-md border border-foreground/30 bg-foreground/5 p-3 text-[11px]">
|
||||
<p className="flex items-center gap-1.5 font-medium text-foreground"><Lock className="size-3.5" />Secrets werden AES-256-GCM verschlüsselt</p>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
Der <code className="font-mono">ENCRYPTION_KEY</code> wird separat vom DB-Backup verwahrt. Ohne ihn sind SMTP, API-Keys und Signier-Schlüssel unwiederherstellbar.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Mail config */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
<FileText className="size-4 text-muted-foreground" />
|
||||
E-Mail (SMTP/IMAP)
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<Field label="SMTP-Host" k="mail.smtpHost" />
|
||||
<Field label="SMTP-Port" k="mail.smtpPort" mono />
|
||||
<Field label="IMAP-Host" k="mail.imapHost" />
|
||||
<Field label="IMAP-Port" k="mail.imapPort" mono />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Save bar */}
|
||||
{dirty && (
|
||||
<div className="sticky bottom-4 z-20 mx-auto flex max-w-md items-center gap-3 rounded-lg border border-foreground/30 bg-background p-3 shadow-lg">
|
||||
<Badge variant="outline" className="gap-1 text-[10px]">
|
||||
<span className="size-1.5 rounded-full bg-foreground" />
|
||||
Ungespeichert
|
||||
</Badge>
|
||||
<span className="flex-1 text-xs text-muted-foreground">Änderungen werden im Audit-Log protokolliert.</span>
|
||||
<Button size="sm" onClick={save} disabled={saving} className="gap-1.5">
|
||||
{saving ? <Loader2 className="size-3.5 animate-spin" /> : <Save className="size-3.5" />}
|
||||
Speichern
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user