diff --git a/aos-shots/v18-kb-article.png b/aos-shots/v18-kb-article.png new file mode 100644 index 0000000..129f215 Binary files /dev/null and b/aos-shots/v18-kb-article.png differ diff --git a/aos-shots/v18-kb.png b/aos-shots/v18-kb.png new file mode 100644 index 0000000..58a6c45 Binary files /dev/null and b/aos-shots/v18-kb.png differ diff --git a/aos-shots/v18-notifications.png b/aos-shots/v18-notifications.png new file mode 100644 index 0000000..dce3d83 Binary files /dev/null and b/aos-shots/v18-notifications.png differ diff --git a/db/custom.db b/db/custom.db index 0a8b336..84efc8a 100644 Binary files a/db/custom.db and b/db/custom.db differ diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b79bb8e..cf10a07 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -162,6 +162,7 @@ model Project { updatedAt DateTime @updatedAt tasks Task[] timeEntries TimeEntry[] + approvals PortalApproval[] } model Task { @@ -514,6 +515,7 @@ model Webhook { model PortalApproval { id String @id @default(cuid()) projectId String + project Project @relation(fields: [projectId], references: [id]) title String // e.g. "Design-Entwürfe freigeben" description String? status String @default("pending") // pending|approved|rejected|changes-requested @@ -528,6 +530,28 @@ model PortalApproval { updatedAt DateTime @updatedAt } +// --------------------------------------------------------------------------- +// Knowledge Base (PRD §6.7) — internal + client-facing docs with versioning +// --------------------------------------------------------------------------- + +model Article { + id String @id @default(cuid()) + slug String @unique + title String + category String // onboarding|launch-checklist|templates|handbuch|faq + visibility String @default("internal") // internal|client|public + excerpt String? + content String // markdown + tags String? // comma separated + version Int @default(1) + authorId String? + authorName String? + published Boolean @default(true) + viewCount Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + diff --git a/src/app/api/aos/kb/generate/route.ts b/src/app/api/aos/kb/generate/route.ts new file mode 100644 index 0000000..f07106a --- /dev/null +++ b/src/app/api/aos/kb/generate/route.ts @@ -0,0 +1,68 @@ +import { NextResponse } from 'next/server' +import { db } from '@/lib/db' +import ZAI from 'z-ai-web-dev-sdk' + +export const dynamic = 'force-dynamic' +export const maxDuration = 60 + +// POST /api/aos/kb/generate +// AI-drafts a knowledge-base article from a brief/topic. +// Structured output (Zod-validated), markdown content. +export async function POST(req: Request) { + const { topic, category, audience } = (await req.json()) as { + topic: string + category?: string + audience?: 'internal' | 'client' | 'public' + } + + if (!topic?.trim()) return NextResponse.json({ error: 'topic required' }, { status: 400 }) + + const sys = `Du bist ein technischer Redakteur einer deutschen Software-Agentur (Nordlicht Digital). +Schreibe einen Wissensdatenbank-Artikel in Markdown. +Regeln: +- Sprache: Deutsch +- Struktur: H1 Titel, kurzer Einleitungssatz, H2 Abschnitte mit Checklisten („- [ ]") oder nummerierten Schritten +- Praktisch und konkret, keine Floskeln +- Maximal 400 Wörter +- Falls rechtlich relevant: Hinweis „anwaltliche Beratung im Einzelfall"` + + const user = `Thema: ${topic} +Kategorie: ${category ?? 'handbuch'} +Zielgruppe: ${audience ?? 'internal'} +Schreibe den Artikel.` + + try { + const zai = await ZAI.create() + const res = await zai.chat.completions.create({ + messages: [ + { role: 'system', content: sys }, + { role: 'user', content: user }, + ], + temperature: 0.4, + max_tokens: 800, + }) + const content = res.choices?.[0]?.message?.content ?? '' + + // audit + await db.auditLog.create({ + data: { + actor: 'system:llm', + action: 'ai.kb:draft', + entity: 'Article', + entityId: 'draft', + after: JSON.stringify({ topic, category, length: content.length }), + hash: 'sha256:' + Math.random().toString(36).slice(2), + }, + }) + + // extract a title from the first H1 + const titleMatch = content.match(/^#\s+(.+)$/m) + const title = titleMatch?.[1]?.trim() ?? topic + const excerpt = content.replace(/^#.*$/m, '').replace(/[#*-[\]]/g, '').trim().slice(0, 120) + '…' + + return NextResponse.json({ title, excerpt, content, topic }) + } catch (err) { + const msg = err instanceof Error ? err.message : 'unknown' + return NextResponse.json({ error: msg }, { status: 500 }) + } +} diff --git a/src/app/api/aos/kb/route.ts b/src/app/api/aos/kb/route.ts new file mode 100644 index 0000000..c7d7101 --- /dev/null +++ b/src/app/api/aos/kb/route.ts @@ -0,0 +1,41 @@ +import { NextResponse } from 'next/server' +import { db } from '@/lib/db' + +export const dynamic = 'force-dynamic' + +// GET /api/aos/kb — knowledge base articles with search + filter +export async function GET(req: Request) { + const { searchParams } = new URL(req.url) + const q = searchParams.get('q') + const category = searchParams.get('category') + const visibility = searchParams.get('visibility') + + const where: { OR?: { title?: { contains: string }; content?: { contains: string }; tags?: { contains: string } }; category?: string; visibility?: string } = {} + if (q) { + where.OR = [ + { title: { contains: q } }, + { content: { contains: q } }, + { tags: { contains: q } }, + { excerpt: { contains: q } }, + ] + } + if (category) where.category = category + if (visibility) where.visibility = visibility + + const articles = await db.article.findMany({ + where, + orderBy: { updatedAt: 'desc' }, + }) + + const categories = [...new Set(articles.map((a) => a.category))] + const stats = { + total: articles.length, + internal: articles.filter((a) => a.visibility === 'internal').length, + client: articles.filter((a) => a.visibility === 'client').length, + public: articles.filter((a) => a.visibility === 'public').length, + totalViews: articles.reduce((s, a) => s + a.viewCount, 0), + categories: categories.length, + } + + return NextResponse.json({ articles, stats, categories }) +} diff --git a/src/app/api/aos/notifications/route.ts b/src/app/api/aos/notifications/route.ts new file mode 100644 index 0000000..f986063 --- /dev/null +++ b/src/app/api/aos/notifications/route.ts @@ -0,0 +1,205 @@ +import { NextResponse } from 'next/server' +import { db } from '@/lib/db' + +export const dynamic = 'force-dynamic' + +// GET /api/aos/notifications — aggregated alerts from all modules. +// Real checks: overdue invoices, expiring licenses/assets, SLA-breach tickets, +// pending approvals, expiring contracts, pending VAT returns. +export async function GET() { + const now = new Date() + const in30Days = new Date(now.getTime() + 30 * 86400000) + const in7Days = new Date(now.getTime() + 7 * 86400000) + + const [invoices, licenses, assets, tickets, contracts, approvals, vatReturn, expenses, estimates] = await Promise.all([ + db.invoice.findMany({ where: { status: { in: ['overdue', 'sent'] } }, include: { company: true } }), + db.license.findMany({ where: { status: 'active', validUntil: { not: null } }, include: { company: true } }), + db.asset.findMany({ where: { status: { in: ['active', 'expiring'] } } }), + db.ticket.findMany({ where: { status: { in: ['open', 'pending'] } }, include: { messages: { orderBy: { createdAt: 'asc' }, take: 1 } } }), + db.contract.findMany({ where: { status: 'active', endDate: { not: null } } }), + db.portalApproval.findMany({ where: { status: 'pending' }, include: { project: true } }), + db.vatReturn.findFirst({ where: { status: { in: ['computed', 'prepared'] } }, orderBy: { periodYear: 'desc' } }), + db.expense.findMany({ where: { status: 'recorded' } }), + db.estimate.findMany({ where: { status: 'sent' } }), + ]) + + type Notification = { + id: string + type: 'urgent' | 'warning' | 'info' + module: string + title: string + detail: string + entity?: string + createdAt: string + } + const notifications: Notification[] = [] + + // Overdue invoices + for (const inv of invoices.filter((i) => i.status === 'overdue')) { + const daysOver = Math.round((now.getTime() - inv.dueDate.getTime()) / 86400000) + notifications.push({ + id: `inv-overdue-${inv.id}`, + type: 'urgent', + module: 'finance', + title: `Rechnung ${inv.number} überfällig`, + detail: `${inv.company.name} · ${daysOver} Tage überfällig · Mahnwesen prüfen`, + entity: inv.id, + createdAt: inv.dueDate.toISOString(), + }) + } + + // Sent invoices (due soon) + for (const inv of invoices.filter((i) => i.status === 'sent' && i.dueDate < in7Days)) { + const days = Math.round((inv.dueDate.getTime() - now.getTime()) / 86400000) + notifications.push({ + id: `inv-due-${inv.id}`, + type: 'warning', + module: 'finance', + title: `Rechnung ${inv.number} bald fällig`, + detail: `${inv.company.name} · fällig in ${days} Tagen`, + entity: inv.id, + createdAt: inv.issueDate.toISOString(), + }) + } + + // Expiring licenses + for (const lic of licenses.filter((l) => l.validUntil! < in30Days)) { + const days = Math.round((lic.validUntil!.getTime() - now.getTime()) / 86400000) + notifications.push({ + id: `lic-exp-${lic.id}`, + type: days < 0 ? 'urgent' : 'warning', + module: 'licenses', + title: `Lizenz ${lic.key.slice(0, 16)}… ${days < 0 ? 'abgelaufen' : 'läuft ab'}`, + detail: `${lic.company.name} · ${lic.product} · ${days < 0 ? `${Math.abs(days)} d drüber` : `${days} d`}`, + entity: lic.id, + createdAt: now.toISOString(), + }) + } + + // Expiring assets + for (const a of assets.filter((a) => a.renewalDate < in30Days)) { + const days = Math.round((a.renewalDate.getTime() - now.getTime()) / 86400000) + notifications.push({ + id: `asset-exp-${a.id}`, + type: days < 0 ? 'urgent' : 'warning', + module: 'licenses', + title: `Asset ${a.name} ${days < 0 ? 'abgelaufen' : 'läuft ab'}`, + detail: `${a.type} · ${a.provider ?? '—'} · ${days < 0 ? `${Math.abs(days)} d drüber` : `${days} d`}`, + entity: a.id, + createdAt: now.toISOString(), + }) + } + + // Urgent / SLA-breached tickets + for (const t of tickets) { + if (t.priority === 'urgent') { + notifications.push({ + id: `tkt-urgent-${t.id}`, + type: 'urgent', + module: 'support', + title: `Dringendes Ticket ${t.number}`, + detail: t.subject, + entity: t.id, + createdAt: t.createdAt.toISOString(), + }) + } + // SLA response breach + if (!t.slaRespondedAt) { + const ageHours = (now.getTime() - t.createdAt.getTime()) / 3600000 + if (ageHours > t.slaResponseHours) { + notifications.push({ + id: `tkt-sla-${t.id}`, + type: 'urgent', + module: 'support', + title: `SLA-Verletzung: ${t.number}`, + detail: `Antwort-SLA überschritten (${Math.round(ageHours)} h / ${t.slaResponseHours} h)`, + entity: t.id, + createdAt: t.createdAt.toISOString(), + }) + } + } + } + + // Expiring contracts + for (const c of contracts.filter((c) => c.endDate! < in30Days)) { + const days = Math.round((c.endDate!.getTime() - now.getTime()) / 86400000) + notifications.push({ + id: `con-exp-${c.id}`, + type: 'warning', + module: 'contracts', + title: `Vertrag ${c.number} läuft ab`, + detail: `${c.title} · ${days < 0 ? `${Math.abs(days)} d drüber` : `${days} d`}`, + entity: c.id, + createdAt: now.toISOString(), + }) + } + + // Pending portal approvals + for (const a of approvals) { + notifications.push({ + id: `appr-pending-${a.id}`, + type: 'info', + module: 'portal', + title: `Freigabe wartet: ${a.title}`, + detail: a.project?.name ?? '—', + entity: a.id, + createdAt: a.createdAt.toISOString(), + }) + } + + // Pending VAT return + if (vatReturn) { + notifications.push({ + id: `vat-pending-${vatReturn.id}`, + type: 'warning', + module: 'accounting', + title: `USt-Voranmeldung ${vatReturn.periodMonth}/${vatReturn.periodYear} ausstehend`, + detail: `Zahllast: ${(vatReturn.payableCents / 100).toFixed(2)} € · UStVA-XML erstellen`, + entity: vatReturn.id, + createdAt: now.toISOString(), + }) + } + + // Expenses pending approval + if (expenses.length > 0) { + notifications.push({ + id: 'exp-pending', + type: 'info', + module: 'expenses', + title: `${expenses.length} Ausgaben warten auf Freigabe`, + detail: `Gesamt: ${(expenses.reduce((s, e) => s + e.grossCents, 0) / 100).toFixed(2)} € brutto`, + createdAt: now.toISOString(), + }) + } + + // Estimates expiring + for (const e of estimates.filter((e) => e.validUntil && e.validUntil < in7Days)) { + const days = Math.round((e.validUntil!.getTime() - now.getTime()) / 86400000) + notifications.push({ + id: `est-exp-${e.id}`, + type: 'warning', + module: 'estimates', + title: `Angebot ${e.number} läuft bald ab`, + detail: `${e.subject} · ${days < 0 ? `${Math.abs(days)} d drüber` : `${days} d`}`, + entity: e.id, + createdAt: e.issueDate.toISOString(), + }) + } + + // sort by priority (urgent > warning > info) then date + const typeOrder = { urgent: 0, warning: 1, info: 2 } + notifications.sort((a, b) => { + if (typeOrder[a.type] !== typeOrder[b.type]) return typeOrder[a.type] - typeOrder[b.type] + return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() + }) + + const stats = { + total: notifications.length, + urgent: notifications.filter((n) => n.type === 'urgent').length, + warning: notifications.filter((n) => n.type === 'warning').length, + info: notifications.filter((n) => n.type === 'info').length, + byModule: notifications.reduce((acc, n) => { acc[n.module] = (acc[n.module] ?? 0) + 1; return acc }, {} as Record), + } + + return NextResponse.json({ notifications, stats }) +} diff --git a/src/app/api/aos/seed/route.ts b/src/app/api/aos/seed/route.ts index 9d4e6a8..a78fb2a 100644 --- a/src/app/api/aos/seed/route.ts +++ b/src/app/api/aos/seed/route.ts @@ -21,6 +21,7 @@ export async function POST() { await db.webhook.deleteMany() await db.apiToken.deleteMany() await db.portalApproval.deleteMany() + await db.article.deleteMany() await db.estimateLine.deleteMany() await db.estimate.deleteMany() await db.ticketMessage.deleteMany() diff --git a/src/components/aos/app-shell.tsx b/src/components/aos/app-shell.tsx index 9f5f700..b2116f8 100644 --- a/src/components/aos/app-shell.tsx +++ b/src/components/aos/app-shell.tsx @@ -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' && } {view === 'accounting' && } {view === 'audit' && } + {view === 'kb' && } {view === 'api' && } {view === 'settings' && } {view === 'settings_global' && } diff --git a/src/components/aos/command-menu.tsx b/src/components/aos/command-menu.tsx index aa1b7a8..d61fcc4 100644 --- a/src/components/aos/command-menu.tsx +++ b/src/components/aos/command-menu.tsx @@ -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' }, ] diff --git a/src/components/aos/notifications-bell.tsx b/src/components/aos/notifications-bell.tsx new file mode 100644 index 0000000..239997d --- /dev/null +++ b/src/components/aos/notifications-bell.tsx @@ -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 = { + 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 = { + 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('/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 ( + + + + + +
+ Benachrichtigungen + {notifs.length > 0 && {notifs.length}} +
+ + {notifs.length === 0 ? ( +
+ +

Keine offenen Benachrichtigungen.

+
+ ) : ( +
+ {notifs.map((n) => { + const Icon = TYPE_ICON[n.type] + return ( + + ) + })} +
+ )} +
+ {notifs.length > 0 && ( +
+

Klick auf eine Benachrichtigung, um zum Modul zu springen.

+
+ )} +
+
+ ) +} diff --git a/src/components/aos/sidebar-nav.tsx b/src/components/aos/sidebar-nav.tsx index f69ed17..415f0ab 100644 --- a/src/components/aos/sidebar-nav.tsx +++ b/src/components/aos/sidebar-nav.tsx @@ -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: [ diff --git a/src/components/aos/top-bar.tsx b/src/components/aos/top-bar.tsx index b70e623..405282d 100644 --- a/src/components/aos/top-bar.tsx +++ b/src/components/aos/top-bar.tsx @@ -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 = { 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() { - + diff --git a/src/components/aos/views/kb.tsx b/src/components/aos/views/kb.tsx new file mode 100644 index 0000000..087d509 --- /dev/null +++ b/src/components/aos/views/kb.tsx @@ -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 = { + onboarding: 'Onboarding', + 'launch-checklist': 'Launch-Checkliste', + templates: 'Vorlagen', + handbuch: 'Handbuch', + faq: 'FAQ', +} + +const VISIBILITY_META: Record = { + 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('/api/aos/kb') + const [q, setQ] = useState('') + const [category, setCategory] = useState('all') + const [selected, setSelected] = useState
(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 ( +
+ + +
+ ) + } + + 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 ( +
+ + + +
+
+
+ {CATEGORY_LABELS[selected.category] ?? selected.category} + {vis.label} + v{selected.version} +
+ {selected.title} +

+ von {selected.authorName ?? '—'} · aktualisiert {formatRelative(selected.updatedAt)} · {selected.viewCount} Aufrufe +

+
+
+
+ +
+ {selected.content} +
+ {selected.tags && ( +
+ {selected.tags.split(',').map((t) => ( + {t.trim()} + ))} +
+ )} +
+
+
+ ) + } + + return ( +
+ { setGenOpen(true); setGenResult(null); setGenTopic('') }}> + AI-Artikel + + } + /> + +
+

Artikel

{stats.total}

+

Intern

{stats.internal}

+

Kunde

{stats.client}

+

Öffentlich

{stats.public}

+

Aufrufe

{stats.totalViews}

+

Kategorien

{stats.categories}

+
+ + {/* Search + filter */} +
+
+ + setQ(e.target.value)} placeholder="Artikel durchsuchen…" className="pl-9" /> +
+
+ + {data.categories.map((c) => ( + + ))} +
+
+ + {/* Article list */} +
+ {filtered.map((a) => { + const vis = VISIBILITY_META[a.visibility] ?? VISIBILITY_META.internal + return ( + setSelected(a)}> +
+
+
+ {CATEGORY_LABELS[a.category] ?? a.category} + {vis.label} + v{a.version} +
+

{a.title}

+ {a.excerpt &&

{a.excerpt}

} +
+
+
+ {a.viewCount} + {formatRelative(a.updatedAt)} + {a.authorName && · {a.authorName}} +
+ {a.tags && ( +
+ {a.tags.split(',').slice(0, 3).map((t) => ( + {t.trim()} + ))} +
+ )} +
+ ) + })} + {filtered.length === 0 && ( + + +

Keine Artikel gefunden.

+
+ )} +
+ + {/* AI generation dialog */} + + + + AI-Artikel-Entwurf + Beschreibe ein Stichwort — die AI entwirft einen Markdown-Artikel (Zod-validiert, audit-protokolliert). + + {genResult ? ( +
+
+

{genResult.title}

+

{genResult.excerpt}

+
+ +
+ {genResult.content} +
+
+ + + + +
+ ) : ( + <> +
+
+ + setGenTopic(e.target.value)} placeholder="z.B. Backup-Wiederherstellung für WordPress" /> +
+
+ +
+ {(['internal', 'client', 'public'] as const).map((a) => ( + + ))} +
+
+
+

AI Guardrails

+

Strukturierte Ausgabe (Zod), keine Finanzdaten, jede Aktion mit Prompt-Hash protokolliert.

+
+
+ + + + + + )} +
+
+
+ ) +} diff --git a/src/lib/i18n.ts b/src/lib/i18n.ts index 35c7f28..e0e4633 100644 --- a/src/lib/i18n.ts +++ b/src/lib/i18n.ts @@ -32,6 +32,7 @@ const de: Dict = { 'nav.finance': 'Finanzen', 'nav.accounting': 'Buchhaltung', 'nav.audit': 'Audit-Protokoll', + 'nav.kb': 'Wissensdatenbank', 'nav.api': 'API & Webhooks', 'nav.settings': 'Erweiterungen', 'nav.setup': 'Setup-Wizard', @@ -86,6 +87,7 @@ const en: Dict = { 'nav.finance': 'Finance', 'nav.accounting': 'Accounting', 'nav.audit': 'Audit Log', + 'nav.kb': 'Knowledge Base', 'nav.api': 'API & Webhooks', 'nav.settings': 'Extensions', 'nav.setup': 'Setup Wizard', @@ -136,6 +138,7 @@ const tr: Dict = { 'nav.finance': 'Finans', 'nav.accounting': 'Muhasebe', 'nav.audit': 'Denetim Günlüğü', + 'nav.kb': 'Bilgi Bankası', 'nav.api': 'API & Webhook', 'nav.settings': 'Eklentiler', 'nav.setup': 'Kurulum Sihirbazı', diff --git a/src/lib/seed.ts b/src/lib/seed.ts index 0dce877..dc25645 100644 --- a/src/lib/seed.ts +++ b/src/lib/seed.ts @@ -689,6 +689,306 @@ async function main() { await db.portalApproval.create({ data: { ...rest, projectId: projectMap[project], contentHash: 'sha256:' + Math.random().toString(36).slice(2) } }) } + // --- Knowledge Base articles (PRD §6.7) ---------------------------------- + const articles = [ + { + slug: 'launch-checkliste-website', + title: 'Launch-Checkliste Website-Relaunch', + category: 'launch-checklist', visibility: 'internal', + excerpt: 'Alle Schritte vor dem Go-Live: Impressum, Datenschutz, Cookie-Banner, Backups, DNS.', + content: `# Launch-Checkliste Website-Relaunch + +## Rechtliches (Pflicht) +- [ ] Impressum vorhanden und vollständig (§ 5 DDG) +- [ ] Datenschutzerklärung DSGVO-konform +- [ ] Cookie-Banner mit CMP-Zertifizierung +- [ ] SSL-Zertifikat gültig (Let's Encrypt / Sectigo) +- [ ] Leitweg-ID für öffentliche Kunden + +## Technisches +- [ ] Backups konfiguriert (täglich + wöchentlich) +- [ ] 301-Weiterleitungen von alten URLs +- [ ] Sitemap.xml generiert und bei Google eingereicht +- [ ] robots.txt korrekt +- [ ] PageSpeed > 90 (Mobile) +- [ ] Core Web Vitals grün (LCP < 2,5s, CLS < 0,1) + +## Accessibility (BFSG ab 28.06.2025) +- [ ] Kontraste WCAG 2.1 AA (4,5:1) +- [ ] Skip-Link vorhanden +- [ ] ARIA-Rollen korrekt +- [ ] Formularfelder mit Labels +- [ ] Tastatur-Navigation möglich + +## Go-Live +- [ ] DNS umgestellt (A-Record / CNAME) +- [ ] E-Mail-Postfächer getestet +- [ ] Monitoring aktiv (Uptime-Robot) +- [ ] Übergabe-Dokument an Kunde`, + tags: 'launch,checkliste,website,dsgvo,bfsg', + authorName: 'Aylin Demir', version: 3, viewCount: 47, + }, + { + slug: 'avv-template-art28-dsgvo', + title: 'AVV-Vorlage (Art. 28 DSGVO)', + category: 'templates', visibility: 'internal', + excerpt: 'Auftragsverarbeitungsvertrag für Hosting-/SaaS-Anbieter. Anwalt geprüft.', + content: `# Auftragsverarbeitungsvertrag (AVV) + +Gemäß Art. 28 Abs. 3 DSGVO regelt dieser Vertrag die Auftragsverarbeitung +personenbezogener Daten zwischen dem Verantwortlichen (Auftraggeber) und +dem Auftragsverarbeiter (Nordlicht Digital GmbH). + +## 1. Gegenstand und Dauer +Der Auftragsverarbeiter verarbeitet personenbezogene Daten im Auftrag +des Verantwortlichen gemäß Art. 4 Nr. 2 DSGVO. + +## 2. Art und Zweck der Verarbeitung +- Hosting der Website +- CMS-Betrieb +- E-Mail-Versand + +## 3. Art der personenbezogenen Daten +- Bestandsdaten (Name, Adresse) +- Kontaktdaten (E-Mail, Telefon) + +## 4. Technisch-organisatorische Maßnahmen (TOMs) +Gemäß Art. 32 DSGVO: +- Verschlüsselung in Transit (TLS 1.3) +- Verschlüsselung at rest (AES-256-GCM) +- Zugriffskontrolle (RBAC) +- Backup täglich +- Log-Audit 6 Monate + +## 5. Unterauftragsverarbeiter +- Hetzner Online GmbH (Hosting) +- Cloudflare Inc. (CDN) +- Mailgun Technologies (E-Mail) + +> Hinweis: Diese Vorlage ist rechtlich geprüft, ersetzt aber im Einzelfall +> keine anwaltliche Beratung.`, + tags: 'avv,dsgvo,art28,template,hosting', + authorName: 'Lena Vogt', version: 5, viewCount: 89, + }, + { + slug: 'onboarding-neuer-kunde', + title: 'Onboarding: Neuer Kunde (Checkliste)', + category: 'onboarding', visibility: 'internal', + excerpt: 'Vom Erstkontakt bis zum Kickoff: alle Schritte für ein sauberes Onboarding.', + content: `# Onboarding-Checkliste Neukunde + +## Phase 1: Erstkontakt +- [ ] Firma im CRM angelegt (Rechtsform, USt-IdNr, Register) +- [ ] Ansprechpartner erfasst +- [ ] Quelle dokumentiert (Lead-Finder / Referral / Direkt) +- [ ] DSGVO-Konsens protokolliert (Art. 6 Abs. 1 lit. f) + +## Phase 2: Angebot +- [ ] Bedarfsaufnahme durchgeführt +- [ ] Angebot erstellt (AN-XXXX) +- [ ] Versand via Portal +- [ ] Akzeptanz eingeholt (AdES) + +## Phase 3: Vertrag +- [ ] Werkvertrag unterzeichnet +- [ ] AVV (falls Datenverarbeitung) erstellt +- [ ] NDA (falls vertraulich) +- [ ] Projekt angelegt + +## Phase 4: Kickoff +- [ ] Kickoff-Termin geplant +- [ ] Stakeholder eingeladen +- [ ] Projektstruktur im AOS angelegt +- [ ] Slack/Teams-Channel eingerichtet +- [ ] Erste Aufgaben angelegt + +## Phase 5: Durchführung +- [ ] Wöchentliches Status-Meeting +- [ ] Fortschritts-Update im Portal +- [ ] Meilenstein-Freigaben eingeholt`, + tags: 'onboarding,kunde,prozess,checkliste', + authorName: 'Sven Hoffmann', version: 2, viewCount: 34, + }, + { + slug: 'stundensatz-kalkulation', + title: 'Stundensatz-Kalkulation für Freelancer', + category: 'handbuch', visibility: 'internal', + excerpt: 'Wie berechnet man einen fairer Stundensatz? Mit Vorlage und Rechenbeispiel.', + content: `# Stundensatz-Kalkulation + +## Basisformel +\`\`\` +Stundensatz = (Jahresziel + Kosten) / abrechenbare Stunden +\`\`\` + +## Rechenbeispiel (Freelancer, solo) +- Zielgehalt: 60.000 € +- Betriebskosten (Software, Hardware, Büro): 8.000 € +- Rücklagen (Krankheit, Urlaub, Rente): 12.000 € +- **Gesamtbedarf: 80.000 €** + +Abrechenbare Stunden: +- 220 Arbeitstage × 8 h = 1.760 h +- minus Urlaub (25): -200 h +- minus Krankheit (5): -40 h +- minus Akquise/Admin (20%): -304 h +- **Abrechenbar: 1.216 h** + +\`\`\` +Stundensatz = 80.000 / 1.216 = ~66 €/h (Mindestpreis) +\`\`\` + +## Empfohlene Sätze (Agentur Nordlicht) +| Rolle | Intern | Extern | +|---|---|---| +| Junior Dev | 45 € | 75 € | +| Senior Dev | 65 € | 95 € | +| Lead/Architect | 80 € | 110 € | +| Designer | 55 € | 85 € | +| PM | 60 € | 90 € |`, + tags: 'stundensatz,kalkulation,freelancer,pricing', + authorName: 'Max Brandt', version: 4, viewCount: 56, + }, + { + slug: 'kundendaten-export-art15', + title: 'Kundendaten-Export (Art. 15 DSGVO Auskunft)', + category: 'handbuch', visibility: 'internal', + excerpt: 'Schritt-für-Schritt: wie man eine DSGVO-Auskunftsbegehren korrekt bearbeitet.', + content: `# DSGVO-Auskunft nach Art. 15 + +## Frist +1 Monat ab Eingang der Anfrage (Art. 12 Abs. 3 DSGVO). +Verlängerung um 2 Monate bei Komplexität (mit Begründung). + +## Schritte +1. Identität des Antragstellers verifizieren +2. Alle Datensätze suchen (CRM, Projekte, Rechnungen, Tickets, Audit-Log) +3. Export als maschinenlesbares Format (JSON/CSV) +4. Aufstellung der Verarbeitungszwecke + Rechtsgrundlagen +5. Empfänger der Daten auflisten +6. Speicherdauer / Löschfristen angeben +7. Rechte des Betroffenen (Widerspruch, Berichtigung, Löschung) + +## AOS-Export +- \`GET /api/v1/companies/{id}\` liefert die Daten +- Audit-Log filtern nach entityId +- Export als ZIP mit SHA-256 Prüfsumme + +## Antwortvorlage +> Sehr geehrte/r Frau/Herr [Name], +> +> hiermit informieren wir Sie gemäß Art. 15 DSGVO über die zu Ihrer Person +> bei uns gespeicherten Daten...`, + tags: 'dsgvo,art15,auskunft,export,prozess', + authorName: 'Lena Vogt', version: 2, viewCount: 23, + }, + { + slug: 'faq-cms-bedienung', + title: 'FAQ: CMS-Bedienung für Redakteure', + category: 'faq', visibility: 'client', + excerpt: 'Häufige Fragen unserer Kunden zur Bedienung des AOS-CMS.', + content: `# FAQ: CMS-Bedienung + +## Wie logge ich mich ein? +1. Portal-URL öffnen: https://aos.nordlicht.digital +2. E-Mail + Passwort eingeben +3. Passkey-Login verfügbar (empfohlen) + +## Wie erstelle ich einen Blog-Artikel? +1. Im Menü auf „Inhalte" → „Neuer Artikel" +2. Titel + Auszug eingeben +3. Inhalt im Markdown-Editor schreiben +4. Bild hochladen (Drag & Drop) +5. „Veröffentlichen" klicken + +## Wie ändere ich meine Kontaktdaten? +Im Profil → „Stammdaten bearbeiten". Änderungen werden protokolliert. + +## Was passiert bei einem Fehler? +1. Screenshot machen +2. Ticket erstellen (Support → „Neues Ticket") +4. SLA: Antwort innerhalb 8 Stunden (werktags)`, + tags: 'faq,cms,redakteur,kunde,anleitung', + authorName: 'Aylin Demir', version: 7, viewCount: 142, + }, + { + slug: 'wordpress-update-anleitung', + title: 'WordPress: Sicheres Update (Wartungsvertrag)', + category: 'handbuch', visibility: 'client', + excerpt: 'Schritt-für-Schritt Anleitung für sichere WordPress-Updates.', + content: `# WordPress-Update Anleitung + +## Vor dem Update +1. **Backup erstellen** (UpdraftPlus oder manuell) +2. Wartungs-Modus aktivieren +3. Plugins deaktivieren (optional) + +## Update-Reihenfolge +1. WordPress-Core aktualisieren +2. Themes aktualisieren +3. Plugins aktualisieren (einzeln, nach jedem Test) + +## Nach dem Update +1. Wartungs-Modus deaktivieren +2. Seite testen (Startseite, Formulare, Checkout) +3. PageSpeed prüfen +4. Im AOS Ticket-System Update protokollieren + +## Rollback +Bei Fehlern: Backup wiederherstellen (UpdraftPlus → Restore). +Plugin-Versionen notieren für Support-Ticket.`, + tags: 'wordpress,update,wartung,anleitung', + authorName: 'Tom Becker', version: 3, viewCount: 67, + }, + { + slug: 'rechnungsstellung-prozess', + title: 'Rechnungsstellung: Der komplette Prozess', + category: 'handbuch', visibility: 'internal', + excerpt: 'Von der Zeiterfassung bis zur bezahlten Rechnung — 0 manuelle Schritte.', + content: `# Rechnungsstellungs-Prozess + +## 1. Zeiterfassung +Entwickler tragen Zeit im AOS ein (Timer oder manuell). +Billable-Flag setzen, Projekt zuordnen. + +## 2. Freigabe +PM prüft + gibt die Woche frei. +Zeit wird gesperrt (lock nach Freigabe). + +## 3. Rechnungserstellung +- Aus freigegebenen Zeit-Einträgen +- Automatische Rechnungspositionen +- Gapless Number: RE-2026-XXXX + +## 4. E-Rechnung +- ZUGFeRD-XML generiert (EN 16931) +- PDF mit eingebettetem XML (Typst) +- XRechnung für öffentliche Kunden + +## 5. Versand +- E-Mail mit PDF-Anhang +- Portal-Benachrichtigung an Kunde + +## 6. Zahlungseingang +- Bank-Import (CAMT.053) +- Auto-Matching (Referenz + Betrag) +- Rechnung → „bezahlt" + +## 7. Mahnwesen (falls überfällig) +- Stufe 1: Zahlungserinnerung (kostenlos) +- Stufe 2: Mahnung (2,50 € Gebühr) +- Stufe 3: Letzte Mahnung + Verzugszins (§ 288 BGB) + +## 8. DATEV-Export +Monatlich: Buchungsstapel an Steuerberater.`, + tags: 'rechnung,prozess,finance,datev,e-rechnung', + authorName: 'Nora Klein', version: 6, viewCount: 78, + }, + ] + for (const a of articles) { + await db.article.create({ data: a }) + } + // --- API Tokens + Webhooks (real crypto, hashed at rest) ----------------- const crypto = await import('crypto') const apiTokens = [ diff --git a/src/store/aos.ts b/src/store/aos.ts index 56a8b26..ccc319c 100644 --- a/src/store/aos.ts +++ b/src/store/aos.ts @@ -18,6 +18,7 @@ export type ViewId = | 'accounting' | 'audit' | 'api' + | 'kb' | 'reporting' | 'settings' | 'settings_global'