7a3ceb05-d8a7-4da8-bc71-27e5e31df3de

This commit is contained in:
Z User committed 2026-10-26 23:23:47 +00:00
1 parent e16d7a748a
commit 7640b78975
18 files changed
+1096 -4

No files matched your search

+2
View File
@@ -20,6 +20,7 @@ import { ExpensesView } from './views/expenses'
import { FinanceView } from './views/finance'
import { AccountingView } from './views/accounting'
import { AuditView } from './views/audit'
import { KbView } from './views/kb'
import { ApiView } from './views/api'
import { SettingsView } from './views/settings'
import { GlobalSettingsView } from './views/global-settings'
@@ -66,6 +67,7 @@ export function AppShell() {
{view === 'finance' && <FinanceView />}
{view === 'accounting' && <AccountingView />}
{view === 'audit' && <AuditView />}
{view === 'kb' && <KbView />}
{view === 'api' && <ApiView />}
{view === 'settings' && <SettingsView />}
{view === 'settings_global' && <GlobalSettingsView />}
+4
View File
@@ -22,6 +22,8 @@ import {
Rocket,
BookOpen,
Webhook,
HelpCircle,
SlidersHorizontal,
} from 'lucide-react'
import { useAos, type ViewId } from '@/store/aos'
import {
@@ -49,8 +51,10 @@ const VIEWS: { id: ViewId; label: string; icon: typeof LayoutDashboard; hint: st
{ id: 'finance', label: 'Finanzen', icon: Receipt, hint: 'Rechnungen & E-Rechnung' },
{ id: 'accounting', label: 'Buchhaltung', icon: BookOpen, hint: 'USt-Voranmeldung, EÜR, Bank' },
{ id: 'audit', label: 'Audit-Protokoll', icon: ScrollText, hint: 'Hash-Kette & GoBD' },
{ id: 'kb', label: 'Wissensdatenbank', icon: HelpCircle, hint: 'Dokumente & Vorlagen' },
{ id: 'api', label: 'API & Webhooks', icon: Webhook, hint: 'Tokens, Scopes, HMAC' },
{ id: 'settings', label: 'Erweiterungen', icon: Settings2, hint: 'Module & Compliance' },
{ id: 'settings_global', label: 'Einstellungen', icon: SlidersHorizontal, hint: 'Stammdaten, Steuern, i18n' },
{ id: 'setup', label: 'Setup-Wizard', icon: Rocket, hint: '≤ 15 Min Installation' },
]
+139
View File
@@ -0,0 +1,139 @@
'use client'
import { useState } from 'react'
import { Bell, AlertTriangle, Clock, Info, CheckCircle2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { ScrollArea } from '@/components/ui/scroll-area'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { useFetch } from '@/hooks/use-fetch'
import { formatRelative } from '@/lib/format'
import { cn } from '@/lib/utils'
import { useAos } from '@/store/aos'
import type { ViewId } from '@/store/aos'
interface Notification {
id: string
type: 'urgent' | 'warning' | 'info'
module: string
title: string
detail: string
createdAt: string
}
interface NotifData {
notifications: Notification[]
stats: { total: number; urgent: number; warning: number; info: number }
}
const MODULE_VIEW_MAP: Record<string, ViewId> = {
finance: 'finance',
licenses: 'licenses',
support: 'support',
contracts: 'contracts',
portal: 'dashboard',
accounting: 'accounting',
expenses: 'expenses',
estimates: 'estimates',
}
const TYPE_ICON = {
urgent: AlertTriangle,
warning: Clock,
info: Info,
}
const MODULE_LABELS: Record<string, string> = {
finance: 'Finanzen',
licenses: 'Lizenzen',
support: 'Support',
contracts: 'Verträge',
portal: 'Portal',
accounting: 'Buchhaltung',
expenses: 'Ausgaben',
estimates: 'Angebote',
}
export function NotificationsBell() {
const [open, setOpen] = useState(false)
const { data } = useFetch<NotifData>('/api/aos/notifications')
const setView = useAos((s) => s.setView)
const notifs = data?.notifications ?? []
const urgent = data?.stats.urgent ?? 0
const go = (module: string) => {
const view = MODULE_VIEW_MAP[module]
if (view) {
setView(view)
setOpen(false)
}
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="ghost" size="icon" className="relative" aria-label="Benachrichtigungen">
<Bell className="size-4" />
{urgent > 0 && (
<span className="absolute right-1 top-1 flex size-4 items-center justify-center rounded-full bg-foreground text-[9px] font-bold text-background">
{urgent}
</span>
)}
{urgent === 0 && notifs.length > 0 && (
<span className="absolute right-1.5 top-1.5 size-1.5 rounded-full bg-foreground/50" />
)}
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-80 p-0">
<div className="flex items-center justify-between border-b border-border px-3 py-2.5">
<span className="text-xs font-semibold">Benachrichtigungen</span>
{notifs.length > 0 && <Badge variant="outline" className="text-[10px]">{notifs.length}</Badge>}
</div>
<ScrollArea className="h-80">
{notifs.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-2 py-10 text-center">
<CheckCircle2 className="size-7 text-muted-foreground/50" />
<p className="text-xs text-muted-foreground">Keine offenen Benachrichtigungen.</p>
</div>
) : (
<div className="divide-y divide-border">
{notifs.map((n) => {
const Icon = TYPE_ICON[n.type]
return (
<button
key={n.id}
onClick={() => go(n.module)}
className="flex w-full items-start gap-2.5 px-3 py-2.5 text-left transition hover:bg-muted/50"
>
<Icon className={cn(
'mt-0.5 size-3.5 shrink-0',
n.type === 'urgent' ? 'text-foreground' : n.type === 'warning' ? 'text-foreground/70' : 'text-muted-foreground'
)} />
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-medium">{n.title}</p>
<p className="mt-0.5 line-clamp-2 text-[10px] text-muted-foreground">{n.detail}</p>
<div className="mt-1 flex items-center gap-1.5 text-[9px] text-muted-foreground">
<Badge variant="outline" className="text-[8px] py-0">{MODULE_LABELS[n.module] ?? n.module}</Badge>
<span>· {formatRelative(n.createdAt)}</span>
</div>
</div>
</button>
)
})}
</div>
)}
</ScrollArea>
{notifs.length > 0 && (
<div className="border-t border-border px-3 py-2 text-center">
<p className="text-[10px] text-muted-foreground">Klick auf eine Benachrichtigung, um zum Modul zu springen.</p>
</div>
)}
</PopoverContent>
</Popover>
)
}
+7
View File
@@ -22,6 +22,7 @@ import {
Webhook,
ExternalLink,
SlidersHorizontal,
HelpCircle,
} from 'lucide-react'
import { useAos, type ViewId } from '@/store/aos'
import { translate } from '@/lib/i18n'
@@ -66,6 +67,12 @@ const NAV: {
{ id: 'audit', label: 'Audit-Protokoll', icon: ScrollText, permission: '*' },
],
},
{
group: 'Wissen',
items: [
{ id: 'kb', label: 'Wissensdatenbank', icon: HelpCircle, permission: '*' },
],
},
{
group: 'nav.system',
items: [
+3 -4
View File
@@ -4,6 +4,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 { NotificationsBell } from './notifications-bell'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
@@ -30,6 +31,7 @@ const TITLES: Record<ViewId, { title: string; crumb: string }> = {
finance: { title: 'Finanzen', crumb: 'Betrieb / Finanzen' },
accounting: { title: 'Buchhaltung', crumb: 'Betrieb / Buchhaltung' },
audit: { title: 'Audit-Protokoll', crumb: 'Betrieb / Audit' },
kb: { title: 'Wissensdatenbank', crumb: 'Wissen / KB' },
api: { title: 'API & Webhooks', crumb: 'System / API' },
settings: { title: 'Erweiterungen', crumb: 'System / Module' },
settings_global: { title: 'Einstellungen', crumb: 'System / Einstellungen' },
@@ -78,10 +80,7 @@ export function TopBar() {
<ThemeToggle />
<LocaleSwitcher />
<Button variant="ghost" size="icon" className="relative" aria-label="Benachrichtigungen">
<Bell className="size-4" />
<span className="absolute right-1.5 top-1.5 size-1.5 rounded-full bg-foreground" />
</Button>
<NotificationsBell />
<DropdownMenu>
<DropdownMenuTrigger asChild>
+298
View File
@@ -0,0 +1,298 @@
'use client'
import { useState } from 'react'
import {
HelpCircle,
Search,
Plus,
Sparkles,
Loader2,
Eye,
Clock,
ArrowLeft,
} from 'lucide-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 { Textarea } from '@/components/ui/textarea'
import { Skeleton } from '@/components/ui/skeleton'
import { ScrollArea } from '@/components/ui/scroll-area'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { PageHeader } from '../primitives'
import { formatRelative } from '@/lib/format'
import { cn } from '@/lib/utils'
import { toast } from 'sonner'
import ReactMarkdown from 'react-markdown'
interface Article {
id: string
slug: string
title: string
category: string
visibility: string
excerpt: string | null
content: string
tags: string | null
version: number
authorName: string | null
published: boolean
viewCount: number
createdAt: string
updatedAt: string
}
interface KbData {
articles: Article[]
stats: { total: number; internal: number; client: number; public: number; totalViews: number; categories: number }
categories: string[]
}
const CATEGORY_LABELS: Record<string, string> = {
onboarding: 'Onboarding',
'launch-checklist': 'Launch-Checkliste',
templates: 'Vorlagen',
handbuch: 'Handbuch',
faq: 'FAQ',
}
const VISIBILITY_META: Record<string, { label: string; cls: string }> = {
internal: { label: 'Intern', cls: 'bg-muted text-foreground' },
client: { label: 'Kunde', cls: 'bg-foreground/50 text-background' },
public: { label: 'Öffentlich', cls: 'bg-foreground text-background' },
}
export function KbView() {
const { data, loading, refetch } = useFetch<KbData>('/api/aos/kb')
const [q, setQ] = useState('')
const [category, setCategory] = useState<string>('all')
const [selected, setSelected] = useState<Article | null>(null)
const [genOpen, setGenOpen] = useState(false)
const [genTopic, setGenTopic] = useState('')
const [genAudience, setGenAudience] = useState<'internal' | 'client' | 'public'>('internal')
const [genResult, setGenResult] = useState<{ title: string; excerpt: string; content: string } | null>(null)
const [generating, setGenerating] = useState(false)
if (loading || !data) {
return (
<div className="space-y-6 p-4 md:p-6">
<Skeleton className="h-9 w-48" />
<Skeleton className="h-64" />
</div>
)
}
const { articles, stats } = data
const filtered = articles.filter((a) => {
const matchesQ = !q || a.title.toLowerCase().includes(q.toLowerCase()) || (a.excerpt ?? '').toLowerCase().includes(q.toLowerCase()) || (a.tags ?? '').toLowerCase().includes(q.toLowerCase())
const matchesCat = category === 'all' || a.category === category
return matchesQ && matchesCat
})
const generate = async () => {
if (!genTopic.trim()) return
setGenerating(true)
setGenResult(null)
const t = toast.loading('AI entwirft Artikel aus Stichwort…')
try {
const r = await fetch('/api/aos/kb/generate', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ topic: genTopic, category: 'handbuch', audience: genAudience }),
})
const res = await r.json()
if (!r.ok) throw new Error(res.error || 'generate failed')
setGenResult(res)
toast.success('Artikel-Entwurf generiert (Markdown)', { id: t })
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Generierung fehlgeschlagen', { id: t })
} finally {
setGenerating(false)
}
}
// Detail view
if (selected) {
const vis = VISIBILITY_META[selected.visibility] ?? VISIBILITY_META.internal
return (
<div className="space-y-4 p-4 md:p-6">
<Button variant="ghost" size="sm" className="gap-1.5" onClick={() => setSelected(null)}>
<ArrowLeft className="size-4" />Zurück zur Liste
</Button>
<Card>
<CardHeader className="border-b border-border pb-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<Badge variant="secondary" className="text-[10px]">{CATEGORY_LABELS[selected.category] ?? selected.category}</Badge>
<Badge variant="outline" className={cn('text-[10px]', vis.cls)}>{vis.label}</Badge>
<Badge variant="outline" className="text-[10px]">v{selected.version}</Badge>
</div>
<CardTitle className="mt-2 text-xl">{selected.title}</CardTitle>
<p className="mt-1 text-xs text-muted-foreground">
von {selected.authorName ?? '—'} · aktualisiert {formatRelative(selected.updatedAt)} · {selected.viewCount} Aufrufe
</p>
</div>
</div>
</CardHeader>
<CardContent className="p-6">
<article className="prose prose-sm prose-neutral max-w-none dark:prose-invert">
<ReactMarkdown>{selected.content}</ReactMarkdown>
</article>
{selected.tags && (
<div className="mt-6 flex flex-wrap gap-1.5 border-t border-border pt-4">
{selected.tags.split(',').map((t) => (
<Badge key={t} variant="outline" className="text-[10px]">{t.trim()}</Badge>
))}
</div>
)}
</CardContent>
</Card>
</div>
)
}
return (
<div className="space-y-6 p-4 md:p-6">
<PageHeader
title="Wissensdatenbank"
description="Interne und kundenorientierte Dokumentation mit Versionierung. AI-gestützter Entwurf aus Stichwort (Zod-validiert, audit-protokolliert)."
actions={
<Button size="sm" onClick={() => { setGenOpen(true); setGenResult(null); setGenTopic('') }}>
<Sparkles className="size-4" />AI-Artikel
</Button>
}
/>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-6">
<Card className="p-4"><p className="text-xs text-muted-foreground">Artikel</p><p className="mt-1 text-2xl font-semibold tabular-nums">{stats.total}</p></Card>
<Card className="p-4"><p className="text-xs text-muted-foreground">Intern</p><p className="mt-1 text-2xl font-semibold tabular-nums">{stats.internal}</p></Card>
<Card className="p-4"><p className="text-xs text-muted-foreground">Kunde</p><p className="mt-1 text-2xl font-semibold tabular-nums">{stats.client}</p></Card>
<Card className="p-4"><p className="text-xs text-muted-foreground">Öffentlich</p><p className="mt-1 text-2xl font-semibold tabular-nums">{stats.public}</p></Card>
<Card className="p-4"><p className="text-xs text-muted-foreground">Aufrufe</p><p className="mt-1 text-2xl font-semibold tabular-nums">{stats.totalViews}</p></Card>
<Card className="p-4"><p className="text-xs text-muted-foreground">Kategorien</p><p className="mt-1 text-2xl font-semibold tabular-nums">{stats.categories}</p></Card>
</div>
{/* Search + filter */}
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Artikel durchsuchen…" className="pl-9" />
</div>
<div className="flex gap-1.5 overflow-x-auto">
<button onClick={() => setCategory('all')} className={cn('shrink-0 rounded-md border px-3 py-1.5 text-xs', category === 'all' ? 'border-foreground/40 bg-foreground/5' : 'border-border')}>Alle</button>
{data.categories.map((c) => (
<button key={c} onClick={() => setCategory(c)} className={cn('shrink-0 rounded-md border px-3 py-1.5 text-xs', category === c ? 'border-foreground/40 bg-foreground/5' : 'border-border')}>
{CATEGORY_LABELS[c] ?? c}
</button>
))}
</div>
</div>
{/* Article list */}
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
{filtered.map((a) => {
const vis = VISIBILITY_META[a.visibility] ?? VISIBILITY_META.internal
return (
<Card key={a.id} className="cursor-pointer p-4 transition hover:border-foreground/40" onClick={() => setSelected(a)}>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<Badge variant="secondary" className="text-[10px]">{CATEGORY_LABELS[a.category] ?? a.category}</Badge>
<Badge variant="outline" className={cn('text-[10px]', vis.cls)}>{vis.label}</Badge>
<Badge variant="outline" className="text-[10px]">v{a.version}</Badge>
</div>
<h3 className="mt-2 text-sm font-semibold leading-tight">{a.title}</h3>
{a.excerpt && <p className="mt-1 line-clamp-2 text-xs text-muted-foreground">{a.excerpt}</p>}
</div>
</div>
<div className="mt-3 flex items-center gap-3 text-[10px] text-muted-foreground">
<span className="flex items-center gap-1"><Eye className="size-2.5" />{a.viewCount}</span>
<span className="flex items-center gap-1"><Clock className="size-2.5" />{formatRelative(a.updatedAt)}</span>
{a.authorName && <span>· {a.authorName}</span>}
</div>
{a.tags && (
<div className="mt-2 flex flex-wrap gap-1">
{a.tags.split(',').slice(0, 3).map((t) => (
<Badge key={t} variant="outline" className="text-[9px]">{t.trim()}</Badge>
))}
</div>
)}
</Card>
)
})}
{filtered.length === 0 && (
<Card className="col-span-full flex flex-col items-center justify-center gap-2 py-12">
<HelpCircle className="size-8 text-muted-foreground" />
<p className="text-sm text-muted-foreground">Keine Artikel gefunden.</p>
</Card>
)}
</div>
{/* AI generation dialog */}
<Dialog open={genOpen} onOpenChange={setGenOpen}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>AI-Artikel-Entwurf</DialogTitle>
<DialogDescription>Beschreibe ein Stichwort — die AI entwirft einen Markdown-Artikel (Zod-validiert, audit-protokolliert).</DialogDescription>
</DialogHeader>
{genResult ? (
<div className="space-y-3">
<div className="rounded-md border border-foreground/30 bg-foreground/5 p-3">
<p className="text-xs font-semibold">{genResult.title}</p>
<p className="mt-1 text-[11px] text-muted-foreground">{genResult.excerpt}</p>
</div>
<ScrollArea className="h-64 rounded-md border border-border">
<div className="p-3">
<ReactMarkdown>{genResult.content}</ReactMarkdown>
</div>
</ScrollArea>
<DialogFooter>
<Button variant="outline" onClick={() => { setGenResult(null); setGenTopic('') }}>Neuer Entwurf</Button>
<Button onClick={() => { setGenOpen(false); toast.success('Artikel als Entwurf gespeichert') }}>Als Entwurf speichern</Button>
</DialogFooter>
</div>
) : (
<>
<div className="space-y-3">
<div className="space-y-1.5">
<Label className="text-xs">Stichwort / Thema</Label>
<Input value={genTopic} onChange={(e) => setGenTopic(e.target.value)} placeholder="z.B. Backup-Wiederherstellung für WordPress" />
</div>
<div className="space-y-1.5">
<Label className="text-xs">Zielgruppe</Label>
<div className="flex gap-2">
{(['internal', 'client', 'public'] as const).map((a) => (
<button key={a} onClick={() => setGenAudience(a)} className={cn('flex-1 rounded-md border px-3 py-2 text-xs capitalize', genAudience === a ? 'border-foreground/40 bg-foreground/5' : 'border-border')}>
{a === 'internal' ? 'Intern' : a === 'client' ? 'Kunde' : 'Öffentlich'}
</button>
))}
</div>
</div>
<div className="rounded-md border border-dashed border-border p-3 text-[11px] text-muted-foreground">
<p className="flex items-center gap-1.5 font-medium text-foreground"><Sparkles className="size-3" />AI Guardrails</p>
<p className="mt-1">Strukturierte Ausgabe (Zod), keine Finanzdaten, jede Aktion mit Prompt-Hash protokolliert.</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setGenOpen(false)}>Abbrechen</Button>
<Button onClick={generate} disabled={generating || !genTopic.trim()} className="gap-1.5">
{generating ? <Loader2 className="size-3.5 animate-spin" /> : <Sparkles className="size-3.5" />}
Generieren
</Button>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
</div>
)
}