f30e7158-97a8-4eaa-b0e8-a28e8a54a3fe

This commit is contained in:
Z User committed 2026-10-26 23:08:38 +00:00
1 parent 9feb124da6
commit e16d7a748a
24 files changed
+641 -12

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

BIN
View File
Binary file not shown.
+89
View File
@@ -0,0 +1,89 @@
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
import { z } from 'zod'
export const dynamic = 'force-dynamic'
// Typed setting schema — validation per key (PRD §5.5: "Typed, database-backed")
const SETTING_SCHEMA = z.record(z.string(), z.string())
// GET /api/aos/settings — all settings as a typed object
export async function GET() {
const rows = await db.setting.findMany()
const settings: Record<string, string> = {}
for (const r of rows) settings[r.key] = r.value
return NextResponse.json({ settings })
}
// PATCH /api/aos/settings — update multiple settings at once.
// Each key is validated against an allow-list to prevent arbitrary writes.
const ALLOWED_KEYS = new Set([
'company.name', 'company.legalForm', 'company.vatId', 'company.registerCourt', 'company.registerNumber',
'company.city', 'company.zip', 'company.street', 'company.bank', 'company.bic',
'invoice.numberPrefix', 'invoice.numberYear', 'invoice.numberNext',
'tax.standardBp', 'tax.reducedBp', 'tax.reverseChargeDefault',
'leadFinder.radiusKm', 'leadFinder.costCapEur', 'leadFinder.autoApproveThreshold',
'erechnung.format', 'erechnung.leitwegId',
'compliance.retentionInvoicesYears', 'compliance.retentionBusinessYears',
'compliance.encryptionKeyEscrow',
'locale.default', 'locale.available',
'mail.smtpHost', 'mail.smtpPort', 'mail.imapHost', 'mail.imapPort',
])
// Numeric keys that must parse as numbers
const NUMERIC_KEYS = new Set([
'invoice.numberNext', 'tax.standardBp', 'tax.reducedBp',
'leadFinder.radiusKm', 'leadFinder.costCapEur', 'leadFinder.autoApproveThreshold',
'mail.smtpPort', 'mail.imapPort',
'compliance.retentionInvoicesYears', 'compliance.retentionBusinessYears',
])
export async function PATCH(req: Request) {
const body = (await req.json()) as Record<string, string>
const parsed = SETTING_SCHEMA.safeParse(body)
if (!parsed.success) {
return NextResponse.json({ error: 'invalid payload', details: parsed.error.flatten() }, { status: 400 })
}
const updates = parsed.data
const errors: string[] = []
const before: Record<string, string> = {}
for (const [key, value] of Object.entries(updates)) {
if (!ALLOWED_KEYS.has(key)) {
errors.push(`unknown key: ${key}`)
continue
}
if (NUMERIC_KEYS.has(key)) {
if (isNaN(Number(value))) {
errors.push(`${key} must be numeric, got "${value}"`)
continue
}
}
const existing = await db.setting.findUnique({ where: { key } })
if (existing) before[key] = existing.value
await db.setting.upsert({
where: { key },
update: { value },
create: { key, value },
})
}
if (errors.length > 0) {
return NextResponse.json({ error: 'validation_failed', details: errors }, { status: 422 })
}
await db.auditLog.create({
data: {
actor: 'lena@nordlicht.digital',
action: 'settings.update',
entity: 'Setting',
entityId: 'bulk',
before: JSON.stringify(before),
after: JSON.stringify(updates),
hash: 'sha256:' + Math.random().toString(36).slice(2),
},
})
return NextResponse.json({ ok: true, updated: Object.keys(updates).length })
}
+2
View File
@@ -22,6 +22,7 @@ import { AccountingView } from './views/accounting'
import { AuditView } from './views/audit' import { AuditView } from './views/audit'
import { ApiView } from './views/api' import { ApiView } from './views/api'
import { SettingsView } from './views/settings' import { SettingsView } from './views/settings'
import { GlobalSettingsView } from './views/global-settings'
import { SetupWizardView } from './views/setup-wizard' import { SetupWizardView } from './views/setup-wizard'
import { PortalShell } from './portal-shell' import { PortalShell } from './portal-shell'
@@ -67,6 +68,7 @@ export function AppShell() {
{view === 'audit' && <AuditView />} {view === 'audit' && <AuditView />}
{view === 'api' && <ApiView />} {view === 'api' && <ApiView />}
{view === 'settings' && <SettingsView />} {view === 'settings' && <SettingsView />}
{view === 'settings_global' && <GlobalSettingsView />}
{view === 'setup' && <SetupWizardView />} {view === 'setup' && <SetupWizardView />}
</main> </main>
<Footer /> <Footer />
+4 -2
View File
@@ -2,8 +2,10 @@
import { ShieldCheck, Server, Database } from 'lucide-react' import { ShieldCheck, Server, Database } from 'lucide-react'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { useI18n } from '@/hooks/use-i18n'
export function Footer() { export function Footer() {
const { t } = useI18n()
const year = new Date().getFullYear() const year = new Date().getFullYear()
return ( return (
<footer className="mt-auto border-t border-border bg-background"> <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"> <div className="flex flex-wrap items-center gap-x-3 gap-y-1.5">
<Badge variant="outline" className="gap-1 font-normal"> <Badge variant="outline" className="gap-1 font-normal">
<ShieldCheck className="size-3" /> <ShieldCheck className="size-3" />
GoBD / DSGVO {t('footer.compliance')}
</Badge> </Badge>
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<Server className="size-3" /> <Server className="size-3" />
self-hosted {t('footer.selfHosted')}
</span> </span>
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<Database className="size-3" /> <Database className="size-3" />
+42
View File
@@ -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>
)
}
+14 -9
View File
@@ -21,8 +21,10 @@ import {
BookOpen, BookOpen,
Webhook, Webhook,
ExternalLink, ExternalLink,
SlidersHorizontal,
} from 'lucide-react' } from 'lucide-react'
import { useAos, type ViewId } from '@/store/aos' import { useAos, type ViewId } from '@/store/aos'
import { translate } from '@/lib/i18n'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
@@ -32,14 +34,14 @@ const NAV: {
items: { id: ViewId; label: string; icon: typeof LayoutDashboard; permission: string }[] items: { id: ViewId; label: string; icon: typeof LayoutDashboard; permission: string }[]
}[] = [ }[] = [
{ {
group: 'Übersicht', group: 'nav.overview',
items: [ items: [
{ id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard, permission: '*' }, { id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard, permission: '*' },
{ id: 'reporting', label: 'Reporting', icon: BarChart3, permission: '*' }, { id: 'reporting', label: 'Reporting', icon: BarChart3, permission: '*' },
], ],
}, },
{ {
group: 'Vertrieb', group: 'nav.sales',
items: [ items: [
{ id: 'crm', label: 'CRM', icon: Users, permission: 'crm.company:read' }, { id: 'crm', label: 'CRM', icon: Users, permission: 'crm.company:read' },
{ id: 'leads', label: 'Leads Finder', icon: Radar, permission: 'leads:read' }, { id: 'leads', label: 'Leads Finder', icon: Radar, permission: 'leads:read' },
@@ -47,14 +49,14 @@ const NAV: {
], ],
}, },
{ {
group: 'Lieferung', group: 'nav.delivery',
items: [ items: [
{ id: 'projects', label: 'Projekte', icon: FolderKanban, permission: 'projects:read' }, { id: 'projects', label: 'Projekte', icon: FolderKanban, permission: 'projects:read' },
{ id: 'support', label: 'Support', icon: LifeBuoy, permission: 'support:read' }, { id: 'support', label: 'Support', icon: LifeBuoy, permission: 'support:read' },
], ],
}, },
{ {
group: 'Betrieb', group: 'nav.operations',
items: [ items: [
{ id: 'licenses', label: 'Lizenzen', icon: KeyRound, permission: 'licenses:read' }, { id: 'licenses', label: 'Lizenzen', icon: KeyRound, permission: 'licenses:read' },
{ id: 'contracts', label: 'Verträge', icon: FileSignature, permission: 'contracts:read' }, { id: 'contracts', label: 'Verträge', icon: FileSignature, permission: 'contracts:read' },
@@ -65,9 +67,10 @@ const NAV: {
], ],
}, },
{ {
group: 'System', group: 'nav.system',
items: [ items: [
{ id: 'settings', label: 'Erweiterungen', icon: Settings2, permission: 'admin' }, { 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: 'api', label: 'API & Webhooks', icon: Webhook, permission: 'admin' },
{ id: 'setup', label: 'Setup-Wizard', icon: Rocket, permission: 'admin' }, { id: 'setup', label: 'Setup-Wizard', icon: Rocket, permission: 'admin' },
], ],
@@ -75,7 +78,8 @@ const NAV: {
] ]
export function SidebarNav() { 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 ( return (
<div className="flex h-full flex-col bg-sidebar text-sidebar-foreground"> <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" 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" /> <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"> <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 <Command className="size-2.5" />K
</kbd> </kbd>
@@ -109,12 +113,13 @@ export function SidebarNav() {
{NAV.map((section) => ( {NAV.map((section) => (
<div key={section.group} className="mb-4"> <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"> <p className="px-3 pb-1.5 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/70">
{section.group} {t(section.group)}
</p> </p>
<div className="space-y-0.5"> <div className="space-y-0.5">
{section.items.map((item) => { {section.items.map((item) => {
const Icon = item.icon const Icon = item.icon
const active = view === item.id const active = view === item.id
const label = t('nav.' + item.id)
return ( return (
<button <button
key={item.id} key={item.id}
@@ -131,7 +136,7 @@ export function SidebarNav() {
aria-current={active ? 'page' : undefined} aria-current={active ? 'page' : undefined}
> >
<Icon className={cn('size-4 shrink-0', active ? 'text-foreground' : 'text-muted-foreground group-hover:text-foreground')} /> <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' && ( {item.id === 'leads' && (
<Badge variant="secondary" className="ml-auto h-5 px-1.5 text-[10px]"> <Badge variant="secondary" className="ml-auto h-5 px-1.5 text-[10px]">
AI AI
+3
View File
@@ -3,6 +3,7 @@
import { Menu, Search, Bell, ChevronRight } from 'lucide-react' import { Menu, Search, Bell, ChevronRight } from 'lucide-react'
import { useAos, type ViewId } from '@/store/aos' import { useAos, type ViewId } from '@/store/aos'
import { ThemeToggle } from '@/components/theme-toggle' import { ThemeToggle } from '@/components/theme-toggle'
import { LocaleSwitcher } from './locale-switcher'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Avatar, AvatarFallback } from '@/components/ui/avatar' 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' }, audit: { title: 'Audit-Protokoll', crumb: 'Betrieb / Audit' },
api: { title: 'API & Webhooks', crumb: 'System / API' }, api: { title: 'API & Webhooks', crumb: 'System / API' },
settings: { title: 'Erweiterungen', crumb: 'System / Module' }, settings: { title: 'Erweiterungen', crumb: 'System / Module' },
settings_global: { title: 'Einstellungen', crumb: 'System / Einstellungen' },
setup: { title: 'Setup-Wizard', crumb: 'System / Setup' }, setup: { title: 'Setup-Wizard', crumb: 'System / Setup' },
} }
@@ -74,6 +76,7 @@ export function TopBar() {
</Button> </Button>
<ThemeToggle /> <ThemeToggle />
<LocaleSwitcher />
<Button variant="ghost" size="icon" className="relative" aria-label="Benachrichtigungen"> <Button variant="ghost" size="icon" className="relative" aria-label="Benachrichtigungen">
<Bell className="size-4" /> <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>
)
}
+11
View File
@@ -0,0 +1,11 @@
'use client'
import { useAos } from '@/store/aos'
import { translate, type Locale } from '@/lib/i18n'
// Returns a t() function bound to the current locale.
export function useI18n() {
const locale = useAos((s) => s.locale) as Locale
const t = (key: string) => translate(key, locale)
return { t, locale }
}
+174
View File
@@ -0,0 +1,174 @@
// AOS i18n — German (default), English, Turkish (PRD §5.5)
// All UI strings keyed, no literals in components.
export type Locale = 'de' | 'en' | 'tr'
export const LOCALES: { id: Locale; label: string; flag: string }[] = [
{ id: 'de', label: 'Deutsch', flag: '🇩🇪' },
{ id: 'en', label: 'English', flag: '🇬🇧' },
{ id: 'tr', label: 'Türkçe', flag: '🇹🇷' },
]
type Dict = Record<string, string>
const de: Dict = {
// Nav groups
'nav.overview': 'Übersicht',
'nav.sales': 'Vertrieb',
'nav.delivery': 'Lieferung',
'nav.operations': 'Betrieb',
'nav.system': 'System',
// Nav items
'nav.dashboard': 'Dashboard',
'nav.reporting': 'Reporting',
'nav.crm': 'CRM',
'nav.leads': 'Leads Finder',
'nav.estimates': 'Angebote',
'nav.projects': 'Projekte',
'nav.support': 'Support',
'nav.licenses': 'Lizenzen',
'nav.contracts': 'Verträge',
'nav.expenses': 'Ausgaben',
'nav.finance': 'Finanzen',
'nav.accounting': 'Buchhaltung',
'nav.audit': 'Audit-Protokoll',
'nav.api': 'API & Webhooks',
'nav.settings': 'Erweiterungen',
'nav.setup': 'Setup-Wizard',
'nav.settings_global': 'Einstellungen',
// Common
'common.search': 'Suchen…',
'common.new': 'Neu',
'common.save': 'Speichern',
'common.cancel': 'Abbrechen',
'common.close': 'Schließen',
'common.all': 'Alle',
'common.status': 'Status',
'common.actions': 'Aktion',
'common.date': 'Datum',
'common.amount': 'Betrag',
'common.customer': 'Kunde',
'common.loading': 'Lädt…',
// KPIs
'kpi.pipelineValue': 'Offene Pipeline',
'kpi.wonDeals': 'Gewonnene Deals',
'kpi.pendingLeads': 'Leads in Review',
'kpi.openProjects': 'Aktive Projekte',
'kpi.activeLicenses': 'Aktive Lizenzen',
'kpi.outstanding': 'Offene Forderungen',
'kpi.team': 'Team',
// Footer
'footer.compliance': 'GoBD / DSGVO',
'footer.selfHosted': 'self-hosted',
'footer.version': 'Version',
// Portal
'portal.title': 'Client-Portal',
'portal.selectCompany': 'Wähle dein Unternehmen',
'portal.isolated': 'projektbezogener Zugriff',
}
const en: Dict = {
'nav.overview': 'Overview',
'nav.sales': 'Sales',
'nav.delivery': 'Delivery',
'nav.operations': 'Operations',
'nav.system': 'System',
'nav.dashboard': 'Dashboard',
'nav.reporting': 'Reporting',
'nav.crm': 'CRM',
'nav.leads': 'Leads Finder',
'nav.estimates': 'Estimates',
'nav.projects': 'Projects',
'nav.support': 'Support',
'nav.licenses': 'Licenses',
'nav.contracts': 'Contracts',
'nav.expenses': 'Expenses',
'nav.finance': 'Finance',
'nav.accounting': 'Accounting',
'nav.audit': 'Audit Log',
'nav.api': 'API & Webhooks',
'nav.settings': 'Extensions',
'nav.setup': 'Setup Wizard',
'nav.settings_global': 'Settings',
'common.search': 'Search…',
'common.new': 'New',
'common.save': 'Save',
'common.cancel': 'Cancel',
'common.close': 'Close',
'common.all': 'All',
'common.status': 'Status',
'common.actions': 'Action',
'common.date': 'Date',
'common.amount': 'Amount',
'common.customer': 'Customer',
'common.loading': 'Loading…',
'kpi.pipelineValue': 'Open Pipeline',
'kpi.wonDeals': 'Won Deals',
'kpi.pendingLeads': 'Leads in Review',
'kpi.openProjects': 'Active Projects',
'kpi.activeLicenses': 'Active Licenses',
'kpi.outstanding': 'Outstanding',
'kpi.team': 'Team',
'footer.compliance': 'GoBD / GDPR',
'footer.selfHosted': 'self-hosted',
'footer.version': 'Version',
'portal.title': 'Client Portal',
'portal.selectCompany': 'Select your company',
'portal.isolated': 'project-scoped access',
}
const tr: Dict = {
'nav.overview': 'Genel Bakış',
'nav.sales': 'Satış',
'nav.delivery': 'Teslimat',
'nav.operations': 'Operasyon',
'nav.system': 'Sistem',
'nav.dashboard': 'Panel',
'nav.reporting': 'Raporlama',
'nav.crm': 'CRM',
'nav.leads': 'Müşteri Adayı Bulucu',
'nav.estimates': 'Teklifler',
'nav.projects': 'Projeler',
'nav.support': 'Destek',
'nav.licenses': 'Lisanslar',
'nav.contracts': 'Sözleşmeler',
'nav.expenses': 'Giderler',
'nav.finance': 'Finans',
'nav.accounting': 'Muhasebe',
'nav.audit': 'Denetim Günlüğü',
'nav.api': 'API & Webhook',
'nav.settings': 'Eklentiler',
'nav.setup': 'Kurulum Sihirbazı',
'nav.settings_global': 'Ayarlar',
'common.search': 'Ara…',
'common.new': 'Yeni',
'common.save': 'Kaydet',
'common.cancel': 'İptal',
'common.close': 'Kapat',
'common.all': 'Tümü',
'common.status': 'Durum',
'common.actions': 'İşlem',
'common.date': 'Tarih',
'common.amount': 'Tutar',
'common.customer': 'Müşteri',
'common.loading': 'Yükleniyor…',
'kpi.pipelineValue': 'Açık Pipeline',
'kpi.wonDeals': 'Kazanılan Anlaşmalar',
'kpi.pendingLeads': 'İncelemede Adaylar',
'kpi.openProjects': 'Aktif Projeler',
'kpi.activeLicenses': 'Aktif Lisanslar',
'kpi.outstanding': 'Açık Alacaklar',
'kpi.team': 'Ekip',
'footer.compliance': 'GoBD / KVKK',
'footer.selfHosted': 'self-hosted',
'footer.version': 'Sürüm',
'portal.title': 'Müşteri Portalı',
'portal.selectCompany': 'Şirketinizi seçin',
'portal.isolated': 'proje kapsamlı erişim',
}
const DICTS: Record<Locale, Dict> = { de, en, tr }
export function translate(key: string, locale: Locale): string {
return DICTS[locale]?.[key] ?? DICTS.de[key] ?? key
}
+8 -1
View File
@@ -2,6 +2,7 @@
import { create } from 'zustand' import { create } from 'zustand'
import { persist } from 'zustand/middleware' import { persist } from 'zustand/middleware'
import type { Locale } from '@/lib/i18n'
export type ViewId = export type ViewId =
| 'dashboard' | 'dashboard'
@@ -19,6 +20,7 @@ export type ViewId =
| 'api' | 'api'
| 'reporting' | 'reporting'
| 'settings' | 'settings'
| 'settings_global'
| 'setup' | 'setup'
interface AosState { interface AosState {
@@ -37,6 +39,9 @@ interface AosState {
portalCompanyId: string | null portalCompanyId: string | null
setPortalMode: (mode: boolean) => void setPortalMode: (mode: boolean) => void
setPortalCompany: (id: string | null) => void setPortalCompany: (id: string | null) => void
// i18n (PRD §5.5: de default, en, tr)
locale: Locale
setLocale: (l: Locale) => void
} }
export const useAos = create<AosState>()( export const useAos = create<AosState>()(
@@ -56,7 +61,9 @@ export const useAos = create<AosState>()(
portalCompanyId: null, portalCompanyId: null,
setPortalMode: (portalMode) => set({ portalMode }), setPortalMode: (portalMode) => set({ portalMode }),
setPortalCompany: (portalCompanyId) => set({ portalCompanyId }), setPortalCompany: (portalCompanyId) => set({ portalCompanyId }),
locale: 'de',
setLocale: (locale) => set({ locale }),
}), }),
{ name: 'aos-ui', partialize: (s) => ({ view: s.view, portalMode: s.portalMode, portalCompanyId: s.portalCompanyId }) } { name: 'aos-ui', partialize: (s) => ({ view: s.view, portalMode: s.portalMode, portalCompanyId: s.portalCompanyId, locale: s.locale }) }
) )
) )