7a3ceb05-d8a7-4da8-bc71-27e5e31df3de
This commit is contained in:
1 parent
e16d7a748a
commit
7640b78975
18 files changed
+1096
-4
No files matched your search
Binary file not shown.
|
After Width: | Height: | Size: 119 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 171 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 186 KiB |
Binary file not shown.
@@ -162,6 +162,7 @@ model Project {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
tasks Task[]
|
tasks Task[]
|
||||||
timeEntries TimeEntry[]
|
timeEntries TimeEntry[]
|
||||||
|
approvals PortalApproval[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model Task {
|
model Task {
|
||||||
@@ -514,6 +515,7 @@ model Webhook {
|
|||||||
model PortalApproval {
|
model PortalApproval {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
projectId String
|
projectId String
|
||||||
|
project Project @relation(fields: [projectId], references: [id])
|
||||||
title String // e.g. "Design-Entwürfe freigeben"
|
title String // e.g. "Design-Entwürfe freigeben"
|
||||||
description String?
|
description String?
|
||||||
status String @default("pending") // pending|approved|rejected|changes-requested
|
status String @default("pending") // pending|approved|rejected|changes-requested
|
||||||
@@ -528,6 +530,28 @@ model PortalApproval {
|
|||||||
updatedAt DateTime @updatedAt
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
@@ -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<string, number>),
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ notifications, stats })
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ export async function POST() {
|
|||||||
await db.webhook.deleteMany()
|
await db.webhook.deleteMany()
|
||||||
await db.apiToken.deleteMany()
|
await db.apiToken.deleteMany()
|
||||||
await db.portalApproval.deleteMany()
|
await db.portalApproval.deleteMany()
|
||||||
|
await db.article.deleteMany()
|
||||||
await db.estimateLine.deleteMany()
|
await db.estimateLine.deleteMany()
|
||||||
await db.estimate.deleteMany()
|
await db.estimate.deleteMany()
|
||||||
await db.ticketMessage.deleteMany()
|
await db.ticketMessage.deleteMany()
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { ExpensesView } from './views/expenses'
|
|||||||
import { FinanceView } from './views/finance'
|
import { FinanceView } from './views/finance'
|
||||||
import { AccountingView } from './views/accounting'
|
import { AccountingView } from './views/accounting'
|
||||||
import { AuditView } from './views/audit'
|
import { AuditView } from './views/audit'
|
||||||
|
import { KbView } from './views/kb'
|
||||||
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 { GlobalSettingsView } from './views/global-settings'
|
||||||
@@ -66,6 +67,7 @@ export function AppShell() {
|
|||||||
{view === 'finance' && <FinanceView />}
|
{view === 'finance' && <FinanceView />}
|
||||||
{view === 'accounting' && <AccountingView />}
|
{view === 'accounting' && <AccountingView />}
|
||||||
{view === 'audit' && <AuditView />}
|
{view === 'audit' && <AuditView />}
|
||||||
|
{view === 'kb' && <KbView />}
|
||||||
{view === 'api' && <ApiView />}
|
{view === 'api' && <ApiView />}
|
||||||
{view === 'settings' && <SettingsView />}
|
{view === 'settings' && <SettingsView />}
|
||||||
{view === 'settings_global' && <GlobalSettingsView />}
|
{view === 'settings_global' && <GlobalSettingsView />}
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ import {
|
|||||||
Rocket,
|
Rocket,
|
||||||
BookOpen,
|
BookOpen,
|
||||||
Webhook,
|
Webhook,
|
||||||
|
HelpCircle,
|
||||||
|
SlidersHorizontal,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useAos, type ViewId } from '@/store/aos'
|
import { useAos, type ViewId } from '@/store/aos'
|
||||||
import {
|
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: 'finance', label: 'Finanzen', icon: Receipt, hint: 'Rechnungen & E-Rechnung' },
|
||||||
{ id: 'accounting', label: 'Buchhaltung', icon: BookOpen, hint: 'USt-Voranmeldung, EÜR, Bank' },
|
{ id: 'accounting', label: 'Buchhaltung', icon: BookOpen, hint: 'USt-Voranmeldung, EÜR, Bank' },
|
||||||
{ id: 'audit', label: 'Audit-Protokoll', icon: ScrollText, hint: 'Hash-Kette & GoBD' },
|
{ 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: 'api', label: 'API & Webhooks', icon: Webhook, hint: 'Tokens, Scopes, HMAC' },
|
||||||
{ id: 'settings', label: 'Erweiterungen', icon: Settings2, hint: 'Module & Compliance' },
|
{ 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' },
|
{ id: 'setup', label: 'Setup-Wizard', icon: Rocket, hint: '≤ 15 Min Installation' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
Webhook,
|
Webhook,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
SlidersHorizontal,
|
SlidersHorizontal,
|
||||||
|
HelpCircle,
|
||||||
} 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 { translate } from '@/lib/i18n'
|
||||||
@@ -66,6 +67,12 @@ const NAV: {
|
|||||||
{ id: 'audit', label: 'Audit-Protokoll', icon: ScrollText, permission: '*' },
|
{ id: 'audit', label: 'Audit-Protokoll', icon: ScrollText, permission: '*' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
group: 'Wissen',
|
||||||
|
items: [
|
||||||
|
{ id: 'kb', label: 'Wissensdatenbank', icon: HelpCircle, permission: '*' },
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
group: 'nav.system',
|
group: 'nav.system',
|
||||||
items: [
|
items: [
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ 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 { LocaleSwitcher } from './locale-switcher'
|
||||||
|
import { NotificationsBell } from './notifications-bell'
|
||||||
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'
|
||||||
@@ -30,6 +31,7 @@ const TITLES: Record<ViewId, { title: string; crumb: string }> = {
|
|||||||
finance: { title: 'Finanzen', crumb: 'Betrieb / Finanzen' },
|
finance: { title: 'Finanzen', crumb: 'Betrieb / Finanzen' },
|
||||||
accounting: { title: 'Buchhaltung', crumb: 'Betrieb / Buchhaltung' },
|
accounting: { title: 'Buchhaltung', crumb: 'Betrieb / Buchhaltung' },
|
||||||
audit: { title: 'Audit-Protokoll', crumb: 'Betrieb / Audit' },
|
audit: { title: 'Audit-Protokoll', crumb: 'Betrieb / Audit' },
|
||||||
|
kb: { title: 'Wissensdatenbank', crumb: 'Wissen / KB' },
|
||||||
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' },
|
settings_global: { title: 'Einstellungen', crumb: 'System / Einstellungen' },
|
||||||
@@ -78,10 +80,7 @@ export function TopBar() {
|
|||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
<LocaleSwitcher />
|
<LocaleSwitcher />
|
||||||
|
|
||||||
<Button variant="ghost" size="icon" className="relative" aria-label="Benachrichtigungen">
|
<NotificationsBell />
|
||||||
<Bell className="size-4" />
|
|
||||||
<span className="absolute right-1.5 top-1.5 size-1.5 rounded-full bg-foreground" />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -32,6 +32,7 @@ const de: Dict = {
|
|||||||
'nav.finance': 'Finanzen',
|
'nav.finance': 'Finanzen',
|
||||||
'nav.accounting': 'Buchhaltung',
|
'nav.accounting': 'Buchhaltung',
|
||||||
'nav.audit': 'Audit-Protokoll',
|
'nav.audit': 'Audit-Protokoll',
|
||||||
|
'nav.kb': 'Wissensdatenbank',
|
||||||
'nav.api': 'API & Webhooks',
|
'nav.api': 'API & Webhooks',
|
||||||
'nav.settings': 'Erweiterungen',
|
'nav.settings': 'Erweiterungen',
|
||||||
'nav.setup': 'Setup-Wizard',
|
'nav.setup': 'Setup-Wizard',
|
||||||
@@ -86,6 +87,7 @@ const en: Dict = {
|
|||||||
'nav.finance': 'Finance',
|
'nav.finance': 'Finance',
|
||||||
'nav.accounting': 'Accounting',
|
'nav.accounting': 'Accounting',
|
||||||
'nav.audit': 'Audit Log',
|
'nav.audit': 'Audit Log',
|
||||||
|
'nav.kb': 'Knowledge Base',
|
||||||
'nav.api': 'API & Webhooks',
|
'nav.api': 'API & Webhooks',
|
||||||
'nav.settings': 'Extensions',
|
'nav.settings': 'Extensions',
|
||||||
'nav.setup': 'Setup Wizard',
|
'nav.setup': 'Setup Wizard',
|
||||||
@@ -136,6 +138,7 @@ const tr: Dict = {
|
|||||||
'nav.finance': 'Finans',
|
'nav.finance': 'Finans',
|
||||||
'nav.accounting': 'Muhasebe',
|
'nav.accounting': 'Muhasebe',
|
||||||
'nav.audit': 'Denetim Günlüğü',
|
'nav.audit': 'Denetim Günlüğü',
|
||||||
|
'nav.kb': 'Bilgi Bankası',
|
||||||
'nav.api': 'API & Webhook',
|
'nav.api': 'API & Webhook',
|
||||||
'nav.settings': 'Eklentiler',
|
'nav.settings': 'Eklentiler',
|
||||||
'nav.setup': 'Kurulum Sihirbazı',
|
'nav.setup': 'Kurulum Sihirbazı',
|
||||||
|
|||||||
+300
@@ -689,6 +689,306 @@ async function main() {
|
|||||||
await db.portalApproval.create({ data: { ...rest, projectId: projectMap[project], contentHash: 'sha256:' + Math.random().toString(36).slice(2) } })
|
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) -----------------
|
// --- API Tokens + Webhooks (real crypto, hashed at rest) -----------------
|
||||||
const crypto = await import('crypto')
|
const crypto = await import('crypto')
|
||||||
const apiTokens = [
|
const apiTokens = [
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export type ViewId =
|
|||||||
| 'accounting'
|
| 'accounting'
|
||||||
| 'audit'
|
| 'audit'
|
||||||
| 'api'
|
| 'api'
|
||||||
|
| 'kb'
|
||||||
| 'reporting'
|
| 'reporting'
|
||||||
| 'settings'
|
| 'settings'
|
||||||
| 'settings_global'
|
| 'settings_global'
|
||||||
|
|||||||
Reference in new issue
Block a user