f30e7158-97a8-4eaa-b0e8-a28e8a54a3fe
No files matched your search
|
After Width: | Height: | Size: 163 KiB |
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 163 KiB |
|
After Width: | Height: | Size: 163 KiB |
|
After Width: | Height: | Size: 162 KiB |
|
After Width: | Height: | Size: 225 KiB |
|
After Width: | Height: | Size: 163 KiB |
|
After Width: | Height: | Size: 225 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 227 KiB |
|
After Width: | Height: | Size: 162 KiB |
|
After Width: | Height: | Size: 227 KiB |
@@ -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 })
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
import type { Locale } from '@/lib/i18n'
|
||||
|
||||
export type ViewId =
|
||||
| 'dashboard'
|
||||
@@ -19,6 +20,7 @@ export type ViewId =
|
||||
| 'api'
|
||||
| 'reporting'
|
||||
| 'settings'
|
||||
| 'settings_global'
|
||||
| 'setup'
|
||||
|
||||
interface AosState {
|
||||
@@ -37,6 +39,9 @@ interface AosState {
|
||||
portalCompanyId: string | null
|
||||
setPortalMode: (mode: boolean) => 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>()(
|
||||
@@ -56,7 +61,9 @@ export const useAos = create<AosState>()(
|
||||
portalCompanyId: null,
|
||||
setPortalMode: (portalMode) => set({ portalMode }),
|
||||
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 }) }
|
||||
)
|
||||
)
|
||||