bb983cb7-102d-4a43-baad-e3307280755d
No files matched your search
|
After Width: | Height: | Size: 115 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 97 KiB |
@@ -0,0 +1,88 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Verify Reporting + Setup Wizard
|
||||||
|
set -u
|
||||||
|
cd /home/z/my-project
|
||||||
|
BASE=http://127.0.0.1:3000
|
||||||
|
SHOTS=/home/z/my-project/aos-shots
|
||||||
|
mkdir -p "$SHOTS"
|
||||||
|
|
||||||
|
echo "=== Start server ==="
|
||||||
|
pkill -f "next dev" 2>/dev/null; pkill -f "tee dev.log" 2>/dev/null; sleep 2
|
||||||
|
bun run dev > dev.log 2>&1 &
|
||||||
|
SRV=$!
|
||||||
|
for i in $(seq 1 90); do curl -s -o /dev/null "$BASE/" 2>/dev/null && break; sleep 1; done
|
||||||
|
echo " ready after ${i}s"; sleep 2
|
||||||
|
|
||||||
|
echo "=== API tests ==="
|
||||||
|
echo -n " /api/aos/reporting -> "; curl -s -o /dev/null -w "HTTP %{http_code}\n" "$BASE/api/aos/reporting"
|
||||||
|
curl -s "$BASE/api/aos/reporting" | python3 -c "
|
||||||
|
import sys,json
|
||||||
|
d=json.load(sys.stdin)
|
||||||
|
p=d['pipeline']
|
||||||
|
print(f' pipeline: {p[\"conversionRate\"]}% conversion, €{p[\"pipelineValue\"]/100:.0f} open, €{p[\"wonValue\"]/100:.0f} won')
|
||||||
|
r=d['recurring']
|
||||||
|
print(f' MRR: €{r[\"mrr\"]/100:.0f}, ARR: €{r[\"arr\"]/100:.0f}, renewals: {len(r[\"renewalsDue\"])}')
|
||||||
|
print(f' utilization: {len(d[\"utilization\"][\"byProject\"])} projects, {len(d[\"utilization\"][\"weekly\"])} weeks')
|
||||||
|
print(f' activity: {len(d[\"activity\"])} groups, estimateWinRate: {d[\"estimateWinRate\"]}%')
|
||||||
|
" 2>/dev/null
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Browser: Reporting view ==="
|
||||||
|
agent-browser open "$BASE/" >/dev/null 2>&1
|
||||||
|
agent-browser wait --load networkidle >/dev/null 2>&1 || true
|
||||||
|
agent-browser wait 3000 >/dev/null 2>&1
|
||||||
|
agent-browser set viewport 1440 900 >/dev/null 2>&1
|
||||||
|
agent-browser find text "Reporting" click >/dev/null 2>&1
|
||||||
|
agent-browser wait 3000 >/dev/null 2>&1
|
||||||
|
H1=$(agent-browser get text h1 2>/dev/null | head -1)
|
||||||
|
echo " Reporting H1: $H1"
|
||||||
|
agent-browser screenshot --full "$SHOTS/v11-reporting.png" >/dev/null 2>&1 && echo " shot OK"
|
||||||
|
|
||||||
|
echo "=== Switch to MRR tab ==="
|
||||||
|
agent-browser find role tab click --name "MRR & Erneuerungen" >/dev/null 2>&1 || agent-browser find text "MRR" click >/dev/null 2>&1
|
||||||
|
agent-browser wait 2000 >/dev/null 2>&1
|
||||||
|
agent-browser screenshot --full "$SHOTS/v11-reporting-mrr.png" >/dev/null 2>&1 && echo " mrr shot OK"
|
||||||
|
|
||||||
|
echo "=== Switch to Auslastung tab ==="
|
||||||
|
agent-browser find role tab click --name "Auslastung" >/dev/null 2>&1 || agent-browser find text "Auslastung" click >/dev/null 2>&1
|
||||||
|
agent-browser wait 2000 >/dev/null 2>&1
|
||||||
|
agent-browser screenshot --full "$SHOTS/v11-reporting-utilization.png" >/dev/null 2>&1 && echo " utilization shot OK"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Browser: Setup Wizard ==="
|
||||||
|
agent-browser find text "Setup-Wizard" click >/dev/null 2>&1
|
||||||
|
agent-browser wait 3000 >/dev/null 2>&1
|
||||||
|
H1=$(agent-browser get text h1 2>/dev/null | head -1)
|
||||||
|
echo " Wizard H1: $H1"
|
||||||
|
agent-browser screenshot --full "$SHOTS/v11-wizard-step1.png" >/dev/null 2>&1 && echo " step1 shot OK"
|
||||||
|
|
||||||
|
echo "=== Click Weiter to step 3 (Modules) ==="
|
||||||
|
agent-browser find text "Weiter & Speichern" click >/dev/null 2>&1
|
||||||
|
agent-browser wait 1500 >/dev/null 2>&1
|
||||||
|
agent-browser find text "Weiter & Speichern" click >/dev/null 2>&1
|
||||||
|
agent-browser wait 1500 >/dev/null 2>&1
|
||||||
|
# now at step 3
|
||||||
|
agent-browser screenshot --full "$SHOTS/v11-wizard-step3-modules.png" >/dev/null 2>&1 && echo " step3 shot OK"
|
||||||
|
|
||||||
|
echo "=== Go to step 4 (Mail) and test ==="
|
||||||
|
agent-browser find text "E-Mail" click >/dev/null 2>&1
|
||||||
|
agent-browser wait 1500 >/dev/null 2>&1
|
||||||
|
agent-browser find text "Test ausführen" click >/dev/null 2>&1
|
||||||
|
agent-browser wait 3000 >/dev/null 2>&1
|
||||||
|
agent-browser screenshot --full "$SHOTS/v11-wizard-step4-mail.png" >/dev/null 2>&1 && echo " step4 mail shot OK"
|
||||||
|
MAIL_RES=$(agent-browser eval "document.body.innerText.includes('Verbindung erfolgreich') || document.body.innerText.includes('SMTP')" 2>/dev/null | tail -1)
|
||||||
|
echo " mail test result visible: $MAIL_RES"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Errors ==="
|
||||||
|
agent-browser errors 2>&1 | head -8
|
||||||
|
echo "--- console last 5 ---"
|
||||||
|
agent-browser console 2>&1 | tail -5
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
agent-browser close >/dev/null 2>&1
|
||||||
|
kill $SRV 2>/dev/null
|
||||||
|
echo "=== dev.log errors ==="
|
||||||
|
grep -iE "error|⨯|500" dev.log | grep -v SIGTERM | tail -8
|
||||||
|
echo "=== DONE ==="
|
||||||
|
ls -la "$SHOTS"/v11-* 2>/dev/null | tail -8
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { db } from '@/lib/db'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
// GET /api/aos/reporting — cross-module dashboards:
|
||||||
|
// 1. Pipeline (deals by stage + value)
|
||||||
|
// 2. Utilization (time per person per week)
|
||||||
|
// 3. MRR & recurring (licenses + retainers + recurring expenses)
|
||||||
|
// 4. Activity (audit events by type, last 30 days)
|
||||||
|
export async function GET() {
|
||||||
|
const [deals, projects, timeEntries, licenses, assets, expenses, contracts, estimates, audit, invoices] = await Promise.all([
|
||||||
|
db.deal.findMany({ include: { company: true } }),
|
||||||
|
db.project.findMany(),
|
||||||
|
db.timeEntry.findMany({ where: { date: { gte: new Date(Date.now() - 84 * 86400000) } } }),
|
||||||
|
db.license.findMany({ where: { status: 'active' }, include: { company: true } }),
|
||||||
|
db.asset.findMany({ where: { rebill: true } }),
|
||||||
|
db.expense.findMany({ where: { recurring: true } }),
|
||||||
|
db.contract.findMany({ where: { status: 'active' } }),
|
||||||
|
db.estimate.findMany(),
|
||||||
|
db.auditLog.findMany({ where: { createdAt: { gte: new Date(Date.now() - 30 * 86400000) } } }),
|
||||||
|
db.invoice.findMany(),
|
||||||
|
])
|
||||||
|
|
||||||
|
// 1. Pipeline by stage
|
||||||
|
const stages = ['lead', 'qualified', 'proposal', 'negotiation', 'won', 'lost']
|
||||||
|
const stageLabels: Record<string, string> = {
|
||||||
|
lead: 'Lead', qualified: 'Qualifiziert', proposal: 'Angebot', negotiation: 'Verhandlung', won: 'Gewonnen', lost: 'Verloren',
|
||||||
|
}
|
||||||
|
const pipeline = stages.map((s) => ({
|
||||||
|
stage: s,
|
||||||
|
label: stageLabels[s],
|
||||||
|
count: deals.filter((d) => d.stage === s).length,
|
||||||
|
valueCents: deals.filter((d) => d.stage === s).reduce((sum, d) => sum + d.valueCents, 0),
|
||||||
|
}))
|
||||||
|
const pipelineValue = pipeline.filter((p) => p.stage !== 'won' && p.stage !== 'lost').reduce((s, p) => s + p.valueCents, 0)
|
||||||
|
const wonValue = pipeline.find((p) => p.stage === 'won')?.valueCents ?? 0
|
||||||
|
const lostValue = pipeline.find((p) => p.stage === 'lost')?.valueCents ?? 0
|
||||||
|
const conversionRate = deals.length > 0
|
||||||
|
? Math.round((deals.filter((d) => d.stage === 'won').length / deals.length) * 100)
|
||||||
|
: 0
|
||||||
|
|
||||||
|
// 2. Utilization (time per assignee, last 12 weeks)
|
||||||
|
const assignees = [...new Set(timeEntries.map((t) => t.userId).filter(Boolean) as string[])]
|
||||||
|
// Since time entries don't have real assignees in seed, group by project as proxy
|
||||||
|
const projectIds = [...new Set(timeEntries.map((t) => t.projectId))]
|
||||||
|
const projectsById = new Map(projects.map((p) => [p.id, p.name]))
|
||||||
|
const utilization = projectsById.size > 0 ? Array.from(projectsById.entries()).slice(0, 8).map(([pid, name]) => {
|
||||||
|
const entries = timeEntries.filter((t) => t.projectId === pid)
|
||||||
|
const totalMin = entries.reduce((s, t) => s + t.minutes, 0)
|
||||||
|
const billableMin = entries.filter((t) => t.billable).reduce((s, t) => s + t.minutes, 0)
|
||||||
|
return {
|
||||||
|
label: name.length > 28 ? name.slice(0, 28) + '…' : name,
|
||||||
|
totalHours: Math.round(totalMin / 60),
|
||||||
|
billableHours: Math.round(billableMin / 60),
|
||||||
|
utilizationPct: totalMin > 0 ? Math.round((billableMin / totalMin) * 100) : 0,
|
||||||
|
}
|
||||||
|
}) : []
|
||||||
|
void assignees
|
||||||
|
|
||||||
|
// weekly time trend (last 12 weeks)
|
||||||
|
const weeks: { label: string; hours: number }[] = []
|
||||||
|
for (let i = 11; i >= 0; i--) {
|
||||||
|
const weekStart = new Date(Date.now() - i * 7 * 86400000)
|
||||||
|
const weekEnd = new Date(weekStart.getTime() + 7 * 86400000)
|
||||||
|
const mins = timeEntries
|
||||||
|
.filter((t) => t.date >= weekStart && t.date < weekEnd)
|
||||||
|
.reduce((s, t) => s + t.minutes, 0)
|
||||||
|
const label = `KW ${Math.ceil((weekStart.getDate() + new Date(weekStart.getFullYear(), weekStart.getMonth(), 1).getDay()) / 7)}`
|
||||||
|
weeks.push({ label, hours: Math.round(mins / 60) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. MRR & recurring
|
||||||
|
// MRR = active recurring contracts (monthly equivalent) + rebilled assets with markup
|
||||||
|
const recurringContractsMrr = contracts
|
||||||
|
.filter((c) => c.type === 'wartung-sla' || c.type === 'dienstvertrag')
|
||||||
|
.reduce((s, c) => {
|
||||||
|
// rough monthly equivalent: value / 12 if annual, else value
|
||||||
|
const monthly = c.endDate ? c.valueCents / 12 : c.valueCents
|
||||||
|
return s + Math.round(monthly / 100) * 100
|
||||||
|
}, 0)
|
||||||
|
const rebillMrr = assets
|
||||||
|
.filter((a) => a.rebill)
|
||||||
|
.reduce((s, a) => s + Math.round((a.costCentsPerMonth ?? 0) * (1 + a.markupPct / 100)), 0)
|
||||||
|
const recurringExpensesMrr = expenses
|
||||||
|
.filter((e) => e.recurring && e.recurringInterval === 'monthly')
|
||||||
|
.reduce((s, e) => s + e.grossCents, 0)
|
||||||
|
const mrr = recurringContractsMrr + rebillMrr
|
||||||
|
const arr = mrr * 12
|
||||||
|
|
||||||
|
// renewals due (next 60 days)
|
||||||
|
const renewalsDue = assets
|
||||||
|
.filter((a) => {
|
||||||
|
const days = (a.renewalDate.getTime() - Date.now()) / 86400000
|
||||||
|
return days <= 60 && a.status !== 'expired'
|
||||||
|
})
|
||||||
|
.map((a) => ({ name: a.name, type: a.type, renewalDate: a.renewalDate, daysUntil: Math.round((a.renewalDate.getTime() - Date.now()) / 86400000) }))
|
||||||
|
.sort((a, b) => a.daysUntil - b.daysUntil)
|
||||||
|
|
||||||
|
// revenue last 6 months (paid invoices)
|
||||||
|
const now = new Date()
|
||||||
|
const revenue: { label: string; cents: number }[] = []
|
||||||
|
for (let i = 5; i >= 0; i--) {
|
||||||
|
const m = new Date(now.getFullYear(), now.getMonth() - i, 1)
|
||||||
|
const label = m.toLocaleDateString('de-DE', { month: 'short' })
|
||||||
|
const cents = invoices
|
||||||
|
.filter((inv) => inv.status === 'paid' && inv.paidDate && inv.paidDate.getMonth() === m.getMonth() && inv.paidDate.getFullYear() === m.getFullYear())
|
||||||
|
.reduce((s, i) => s + i.grossCents, 0)
|
||||||
|
revenue.push({ label, cents })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Activity (audit by action group, last 30 days)
|
||||||
|
const actionGroups: Record<string, number> = {}
|
||||||
|
for (const a of audit) {
|
||||||
|
const group = a.action.split(':')[0]
|
||||||
|
actionGroups[group] = (actionGroups[group] ?? 0) + 1
|
||||||
|
}
|
||||||
|
const activity = Object.entries(actionGroups)
|
||||||
|
.map(([group, count]) => ({ group, count }))
|
||||||
|
.sort((a, b) => b.count - a.count)
|
||||||
|
|
||||||
|
// estimate win-rate
|
||||||
|
const decided = estimates.filter((e) => e.status === 'accepted' || e.status === 'rejected')
|
||||||
|
const estimateWinRate = decided.length > 0
|
||||||
|
? Math.round((decided.filter((e) => e.status === 'accepted').length / decided.length) * 100)
|
||||||
|
: 0
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
pipeline: { stages: pipeline, pipelineValue, wonValue, lostValue, conversionRate, avgDealSizeCents: deals.length ? Math.round(deals.reduce((s, d) => s + d.valueCents, 0) / deals.length) : 0 },
|
||||||
|
utilization: { byProject: utilization, weekly: weeks },
|
||||||
|
recurring: { mrr, arr, recurringContractsMrr, rebillMrr, recurringExpensesMrr, renewalsDue, activeLicenses: licenses.length },
|
||||||
|
revenue,
|
||||||
|
activity,
|
||||||
|
estimateWinRate,
|
||||||
|
ticketStats: { total: 0 }, // filled by tickets endpoint
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
export const maxDuration = 30
|
||||||
|
|
||||||
|
// POST /api/aos/setup/test-mail
|
||||||
|
// Wizard step 4: test SMTP/IMAP connection.
|
||||||
|
// In a real deployment this would actually connect; here we simulate the test.
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const body = (await req.json().catch(() => ({}))) as {
|
||||||
|
smtpHost?: string
|
||||||
|
smtpPort?: number
|
||||||
|
imapHost?: string
|
||||||
|
imapPort?: number
|
||||||
|
user?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// simulate a connection check with a short delay
|
||||||
|
await new Promise((r) => setTimeout(r, 800))
|
||||||
|
|
||||||
|
const smtpOk = !!body.smtpHost && !!body.smtpPort && !!body.user
|
||||||
|
const imapOk = !!body.imapHost && !!body.imapPort
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
smtp: { ok: smtpOk, host: body.smtpHost ?? null, port: body.smtpPort ?? null, message: smtpOk ? 'Verbindung erfolgreich (220 smtp.nordlicht.digital ESMTP)' : 'SMTP-Konfiguration unvollständig' },
|
||||||
|
imap: { ok: imapOk, host: body.imapHost ?? null, port: body.imapPort ?? null, message: imapOk ? 'INBOX erreichbar (3 ungelesene)' : 'IMAP-Konfiguration unvollständig' },
|
||||||
|
testedAt: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import { CommandMenu } from './command-menu'
|
|||||||
import { CompanyDrawer } from './company-drawer'
|
import { CompanyDrawer } from './company-drawer'
|
||||||
import { Sheet, SheetContent, SheetTitle } from '@/components/ui/sheet'
|
import { Sheet, SheetContent, SheetTitle } from '@/components/ui/sheet'
|
||||||
import { DashboardView } from './views/dashboard'
|
import { DashboardView } from './views/dashboard'
|
||||||
|
import { ReportingView } from './views/reporting'
|
||||||
import { CrmView } from './views/crm'
|
import { CrmView } from './views/crm'
|
||||||
import { LeadsView } from './views/leads'
|
import { LeadsView } from './views/leads'
|
||||||
import { EstimatesView } from './views/estimates'
|
import { EstimatesView } from './views/estimates'
|
||||||
@@ -19,6 +20,7 @@ import { ExpensesView } from './views/expenses'
|
|||||||
import { FinanceView } from './views/finance'
|
import { FinanceView } from './views/finance'
|
||||||
import { AuditView } from './views/audit'
|
import { AuditView } from './views/audit'
|
||||||
import { SettingsView } from './views/settings'
|
import { SettingsView } from './views/settings'
|
||||||
|
import { SetupWizardView } from './views/setup-wizard'
|
||||||
|
|
||||||
export function AppShell() {
|
export function AppShell() {
|
||||||
const { view, sidebarOpen, setSidebarOpen } = useAos()
|
const { view, sidebarOpen, setSidebarOpen } = useAos()
|
||||||
@@ -45,6 +47,7 @@ export function AppShell() {
|
|||||||
<TopBar />
|
<TopBar />
|
||||||
<main className="flex-1">
|
<main className="flex-1">
|
||||||
{view === 'dashboard' && <DashboardView />}
|
{view === 'dashboard' && <DashboardView />}
|
||||||
|
{view === 'reporting' && <ReportingView />}
|
||||||
{view === 'crm' && <CrmView />}
|
{view === 'crm' && <CrmView />}
|
||||||
{view === 'leads' && <LeadsView />}
|
{view === 'leads' && <LeadsView />}
|
||||||
{view === 'estimates' && <EstimatesView />}
|
{view === 'estimates' && <EstimatesView />}
|
||||||
@@ -56,6 +59,7 @@ export function AppShell() {
|
|||||||
{view === 'finance' && <FinanceView />}
|
{view === 'finance' && <FinanceView />}
|
||||||
{view === 'audit' && <AuditView />}
|
{view === 'audit' && <AuditView />}
|
||||||
{view === 'settings' && <SettingsView />}
|
{view === 'settings' && <SettingsView />}
|
||||||
|
{view === 'setup' && <SetupWizardView />}
|
||||||
</main>
|
</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import {
|
|||||||
Wallet,
|
Wallet,
|
||||||
FileText,
|
FileText,
|
||||||
LifeBuoy,
|
LifeBuoy,
|
||||||
|
BarChart3,
|
||||||
|
Rocket,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useAos, type ViewId } from '@/store/aos'
|
import { useAos, type ViewId } from '@/store/aos'
|
||||||
import {
|
import {
|
||||||
@@ -33,6 +35,7 @@ import { toast } from 'sonner'
|
|||||||
|
|
||||||
const VIEWS: { id: ViewId; label: string; icon: typeof LayoutDashboard; hint: string }[] = [
|
const VIEWS: { id: ViewId; label: string; icon: typeof LayoutDashboard; hint: string }[] = [
|
||||||
{ id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard, hint: 'Übersicht & KPIs' },
|
{ id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard, hint: 'Übersicht & KPIs' },
|
||||||
|
{ id: 'reporting', label: 'Reporting', icon: BarChart3, hint: 'Pipeline, Auslastung, MRR' },
|
||||||
{ id: 'crm', label: 'CRM', icon: Users, hint: 'Firmen & Pipeline' },
|
{ id: 'crm', label: 'CRM', icon: Users, hint: 'Firmen & Pipeline' },
|
||||||
{ id: 'leads', label: 'Leads Finder', icon: Radar, hint: 'Review-Queue' },
|
{ id: 'leads', label: 'Leads Finder', icon: Radar, hint: 'Review-Queue' },
|
||||||
{ id: 'estimates', label: 'Angebote', icon: FileText, hint: 'Quotes & Conversion' },
|
{ id: 'estimates', label: 'Angebote', icon: FileText, hint: 'Quotes & Conversion' },
|
||||||
@@ -44,6 +47,7 @@ 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: 'audit', label: 'Audit-Protokoll', icon: ScrollText, hint: 'Hash-Kette & GoBD' },
|
{ id: 'audit', label: 'Audit-Protokoll', icon: ScrollText, hint: 'Hash-Kette & GoBD' },
|
||||||
{ id: 'settings', label: 'Erweiterungen', icon: Settings2, hint: 'Module & Compliance' },
|
{ id: 'settings', label: 'Erweiterungen', icon: Settings2, hint: 'Module & Compliance' },
|
||||||
|
{ id: 'setup', label: 'Setup-Wizard', icon: Rocket, hint: '≤ 15 Min Installation' },
|
||||||
]
|
]
|
||||||
|
|
||||||
interface SearchResult {
|
interface SearchResult {
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import {
|
|||||||
Wallet,
|
Wallet,
|
||||||
FileText,
|
FileText,
|
||||||
LifeBuoy,
|
LifeBuoy,
|
||||||
|
BarChart3,
|
||||||
|
Rocket,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useAos, type ViewId } from '@/store/aos'
|
import { useAos, type ViewId } from '@/store/aos'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
@@ -28,7 +30,10 @@ const NAV: {
|
|||||||
}[] = [
|
}[] = [
|
||||||
{
|
{
|
||||||
group: 'Übersicht',
|
group: 'Übersicht',
|
||||||
items: [{ id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard, permission: '*' }],
|
items: [
|
||||||
|
{ id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard, permission: '*' },
|
||||||
|
{ id: 'reporting', label: 'Reporting', icon: BarChart3, permission: '*' },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: 'Vertrieb',
|
group: 'Vertrieb',
|
||||||
@@ -57,7 +62,10 @@ const NAV: {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: 'System',
|
group: 'System',
|
||||||
items: [{ id: 'settings', label: 'Erweiterungen', icon: Settings2, permission: 'admin' }],
|
items: [
|
||||||
|
{ id: 'settings', label: 'Erweiterungen', icon: Settings2, permission: 'admin' },
|
||||||
|
{ id: 'setup', label: 'Setup-Wizard', icon: Rocket, permission: 'admin' },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
|
|
||||||
const TITLES: Record<ViewId, { title: string; crumb: string }> = {
|
const TITLES: Record<ViewId, { title: string; crumb: string }> = {
|
||||||
dashboard: { title: 'Dashboard', crumb: 'Übersicht' },
|
dashboard: { title: 'Dashboard', crumb: 'Übersicht' },
|
||||||
|
reporting: { title: 'Reporting', crumb: 'Übersicht / Reporting' },
|
||||||
crm: { title: 'CRM', crumb: 'Vertrieb / CRM' },
|
crm: { title: 'CRM', crumb: 'Vertrieb / CRM' },
|
||||||
leads: { title: 'Leads Finder', crumb: 'Vertrieb / Leads' },
|
leads: { title: 'Leads Finder', crumb: 'Vertrieb / Leads' },
|
||||||
estimates: { title: 'Angebote', crumb: 'Vertrieb / Angebote' },
|
estimates: { title: 'Angebote', crumb: 'Vertrieb / Angebote' },
|
||||||
@@ -28,6 +29,7 @@ const TITLES: Record<ViewId, { title: string; crumb: string }> = {
|
|||||||
finance: { title: 'Finanzen', crumb: 'Betrieb / Finanzen' },
|
finance: { title: 'Finanzen', crumb: 'Betrieb / Finanzen' },
|
||||||
audit: { title: 'Audit-Protokoll', crumb: 'Betrieb / Audit' },
|
audit: { title: 'Audit-Protokoll', crumb: 'Betrieb / Audit' },
|
||||||
settings: { title: 'Erweiterungen', crumb: 'System / Module' },
|
settings: { title: 'Erweiterungen', crumb: 'System / Module' },
|
||||||
|
setup: { title: 'Setup-Wizard', crumb: 'System / Setup' },
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TopBar() {
|
export function TopBar() {
|
||||||
|
|||||||
@@ -0,0 +1,397 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import {
|
||||||
|
BarChart3,
|
||||||
|
TrendingUp,
|
||||||
|
Users,
|
||||||
|
Repeat,
|
||||||
|
Activity,
|
||||||
|
CalendarClock,
|
||||||
|
ArrowRight,
|
||||||
|
} 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 { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
||||||
|
import { PageHeader } from '../primitives'
|
||||||
|
import { formatEuro, formatEuroCompact, formatDate } from '@/lib/format'
|
||||||
|
import { useAos } from '@/store/aos'
|
||||||
|
import {
|
||||||
|
Bar,
|
||||||
|
BarChart,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
Cell,
|
||||||
|
Line,
|
||||||
|
LineChart,
|
||||||
|
CartesianGrid,
|
||||||
|
Area,
|
||||||
|
AreaChart,
|
||||||
|
} from 'recharts'
|
||||||
|
|
||||||
|
interface ReportingData {
|
||||||
|
pipeline: {
|
||||||
|
stages: { stage: string; label: string; count: number; valueCents: number }[]
|
||||||
|
pipelineValue: number
|
||||||
|
wonValue: number
|
||||||
|
lostValue: number
|
||||||
|
conversionRate: number
|
||||||
|
avgDealSizeCents: number
|
||||||
|
}
|
||||||
|
utilization: {
|
||||||
|
byProject: { label: string; totalHours: number; billableHours: number; utilizationPct: number }[]
|
||||||
|
weekly: { label: string; hours: number }[]
|
||||||
|
}
|
||||||
|
recurring: {
|
||||||
|
mrr: number
|
||||||
|
arr: number
|
||||||
|
recurringContractsMrr: number
|
||||||
|
rebillMrr: number
|
||||||
|
recurringExpensesMrr: number
|
||||||
|
renewalsDue: { name: string; type: string; renewalDate: string; daysUntil: number }[]
|
||||||
|
activeLicenses: number
|
||||||
|
}
|
||||||
|
revenue: { label: string; cents: number }[]
|
||||||
|
activity: { group: string; count: number }[]
|
||||||
|
estimateWinRate: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReportingView() {
|
||||||
|
const { data, loading } = useFetch<ReportingData>('/api/aos/reporting')
|
||||||
|
const setView = useAos((s) => s.setView)
|
||||||
|
|
||||||
|
if (loading || !data) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 p-4 md:p-6">
|
||||||
|
<Skeleton className="h-9 w-32" />
|
||||||
|
<Skeleton className="h-64" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { pipeline, utilization, recurring, revenue, activity } = data
|
||||||
|
const maxStageValue = Math.max(...pipeline.stages.map((s) => s.valueCents), 1)
|
||||||
|
const maxHours = Math.max(...utilization.weekly.map((w) => w.hours), 1)
|
||||||
|
const maxRev = Math.max(...revenue.map((r) => r.cents), 1)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 p-4 md:p-6">
|
||||||
|
<PageHeader
|
||||||
|
title="Reporting"
|
||||||
|
description="Cross-module Dashboards: Pipeline, Auslastung, wiederkehrende Umsätze (MRR/ARR), Aktivität. Datengetrieben, keine manuelle Zusammenstellung."
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* KPI summary */}
|
||||||
|
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-xs text-muted-foreground">Offene Pipeline</p>
|
||||||
|
<TrendingUp className="size-3.5 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-2xl font-semibold tabular-nums">{formatEuroCompact(pipeline.pipelineValue)}</p>
|
||||||
|
<p className="text-[10px] text-muted-foreground">Conversion: {pipeline.conversionRate}%</p>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-xs text-muted-foreground">MRR</p>
|
||||||
|
<Repeat className="size-3.5 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-2xl font-semibold tabular-nums">{formatEuroCompact(recurring.mrr)}</p>
|
||||||
|
<p className="text-[10px] text-muted-foreground">ARR: {formatEuroCompact(recurring.arr)}</p>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-xs text-muted-foreground">Ø Deal-Größe</p>
|
||||||
|
<BarChart3 className="size-3.5 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-2xl font-semibold tabular-nums">{formatEuroCompact(pipeline.avgDealSizeCents)}</p>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-xs text-muted-foreground">Angebot-Win-Rate</p>
|
||||||
|
<ArrowRight className="size-3.5 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-2xl font-semibold tabular-nums">{data.estimateWinRate}<span className="text-sm text-muted-foreground">%</span></p>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs defaultValue="pipeline">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="pipeline">Pipeline</TabsTrigger>
|
||||||
|
<TabsTrigger value="utilization">Auslastung</TabsTrigger>
|
||||||
|
<TabsTrigger value="recurring">MRR & Erneuerungen</TabsTrigger>
|
||||||
|
<TabsTrigger value="activity">Aktivität</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{/* Pipeline tab */}
|
||||||
|
<TabsContent value="pipeline" className="mt-4 space-y-4">
|
||||||
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||||
|
<Card className="lg:col-span-2">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm">Pipeline nach Phase</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
{pipeline.stages.filter((s) => s.stage !== 'lost').map((s) => (
|
||||||
|
<div key={s.stage}>
|
||||||
|
<div className="mb-1 flex items-center justify-between text-xs">
|
||||||
|
<span className="font-medium">{s.label}</span>
|
||||||
|
<span className="text-muted-foreground">{s.count} Deals · {formatEuroCompact(s.valueCents)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2.5 w-full overflow-hidden rounded bg-muted">
|
||||||
|
<div className="h-full rounded bg-foreground transition-all duration-500" style={{ width: `${(s.valueCents / maxStageValue) * 100}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm">Umsatz (6 Monate, bezahlt)</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ResponsiveContainer width="100%" height={200}>
|
||||||
|
<AreaChart data={revenue} margin={{ top: 4, right: 4, bottom: 0, left: -20 }}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="revGrad" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor="var(--foreground)" stopOpacity={0.3} />
|
||||||
|
<stop offset="100%" stopColor="var(--foreground)" stopOpacity={0} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<XAxis dataKey="label" tick={{ fontSize: 11, fill: 'var(--muted-foreground)' }} axisLine={false} tickLine={false} />
|
||||||
|
<YAxis tick={{ fontSize: 11, fill: 'var(--muted-foreground)' }} axisLine={false} tickLine={false} tickFormatter={(v) => formatEuroCompact(v).replace(' €', '')} width={50} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{ background: 'var(--popover)', border: '1px solid var(--border)', borderRadius: 'var(--radius)', fontSize: 12 }}
|
||||||
|
formatter={(v: number) => [formatEuro(v), 'Umsatz']}
|
||||||
|
/>
|
||||||
|
<Area type="monotone" dataKey="cents" stroke="var(--foreground)" strokeWidth={2} fill="url(#revGrad)" />
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
|
<Card className="p-4">
|
||||||
|
<p className="text-xs text-muted-foreground">Gewonnen (YTD)</p>
|
||||||
|
<p className="mt-1 text-xl font-semibold tabular-nums">{formatEuro(pipeline.wonValue)}</p>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-4">
|
||||||
|
<p className="text-xs text-muted-foreground">Verloren</p>
|
||||||
|
<p className="mt-1 text-xl font-semibold tabular-nums text-muted-foreground">{formatEuro(pipeline.lostValue)}</p>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-4">
|
||||||
|
<p className="text-xs text-muted-foreground">Conversion-Rate</p>
|
||||||
|
<p className="mt-1 text-xl font-semibold tabular-nums">{pipeline.conversionRate}%</p>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Utilization tab */}
|
||||||
|
<TabsContent value="utilization" className="mt-4 space-y-4">
|
||||||
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-sm">
|
||||||
|
<Users className="size-3.5 text-muted-foreground" />
|
||||||
|
Auslastung nach Projekt
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
{utilization.byProject.map((u) => (
|
||||||
|
<div key={u.label}>
|
||||||
|
<div className="mb-1 flex items-center justify-between text-xs">
|
||||||
|
<span className="truncate font-medium">{u.label}</span>
|
||||||
|
<span className="tabular-nums text-muted-foreground">{u.billableHours}/{u.totalHours} h · {u.utilizationPct}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 w-full overflow-hidden rounded bg-muted">
|
||||||
|
<div className="h-full rounded bg-foreground" style={{ width: `${u.utilizationPct}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm">Wöchentliche Stunden (12 Wochen)</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ResponsiveContainer width="100%" height={200}>
|
||||||
|
<BarChart data={utilization.weekly} margin={{ top: 4, right: 4, bottom: 0, left: -20 }}>
|
||||||
|
<XAxis dataKey="label" tick={{ fontSize: 10, fill: 'var(--muted-foreground)' }} axisLine={false} tickLine={false} />
|
||||||
|
<YAxis tick={{ fontSize: 11, fill: 'var(--muted-foreground)' }} axisLine={false} tickLine={false} width={40} />
|
||||||
|
<Tooltip
|
||||||
|
cursor={{ fill: 'var(--muted)', opacity: 0.4 }}
|
||||||
|
contentStyle={{ background: 'var(--popover)', border: '1px solid var(--border)', borderRadius: 'var(--radius)', fontSize: 12 }}
|
||||||
|
formatter={(v: number) => [`${v} h`, 'Stunden']}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="hours" radius={[3, 3, 0, 0]}>
|
||||||
|
{utilization.weekly.map((_, i) => (
|
||||||
|
<Cell key={i} fill="var(--foreground)" fillOpacity={0.35 + (i / utilization.weekly.length) * 0.65} />
|
||||||
|
))}
|
||||||
|
</Bar>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* MRR & Renewals tab */}
|
||||||
|
<TabsContent value="recurring" className="mt-4 space-y-4">
|
||||||
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm">MRR-Zusammensetzung</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<MrrRow label="Wartungs-/Dienstverträge" cents={recurring.recurringContractsMrr} total={recurring.mrr} />
|
||||||
|
<MrrRow label="Weiterbelastete Assets (+Markup)" cents={recurring.rebillMrr} total={recurring.mrr} />
|
||||||
|
<MrrRow label="Wiederkehrende Ausgaben" cents={recurring.recurringExpensesMrr} total={recurring.mrr} negative />
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 border-t border-border pt-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-semibold">MRR gesamt</span>
|
||||||
|
<span className="text-lg font-semibold tabular-nums">{formatEuro(recurring.mrr)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex items-center justify-between text-xs text-muted-foreground">
|
||||||
|
<span>ARR (×12)</span>
|
||||||
|
<span className="tabular-nums">{formatEuro(recurring.arr)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-sm">
|
||||||
|
<CalendarClock className="size-3.5 text-muted-foreground" />
|
||||||
|
Erneuerungen (60 Tage)
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-2 max-h-56 overflow-y-auto [scrollbar-width:thin]">
|
||||||
|
{recurring.renewalsDue.length === 0 && (
|
||||||
|
<p className="py-6 text-center text-xs text-muted-foreground">Keine anstehenden Erneuerungen.</p>
|
||||||
|
)}
|
||||||
|
{recurring.renewalsDue.map((r, i) => (
|
||||||
|
<div key={i} className="flex items-center justify-between rounded-md border border-border p-2 text-xs">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate font-medium">{r.name}</p>
|
||||||
|
<p className="text-[10px] text-muted-foreground">{r.type} · {formatDate(r.renewalDate)}</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant="outline" className={r.daysUntil < 7 ? 'border-foreground/50' : ''}>
|
||||||
|
{r.daysUntil < 0 ? `${Math.abs(r.daysUntil)} d drüber` : `${r.daysUntil} d`}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||||
|
<Card className="p-4">
|
||||||
|
<p className="text-xs text-muted-foreground">Aktive Lizenzen</p>
|
||||||
|
<p className="mt-1 text-xl font-semibold tabular-nums">{recurring.activeLicenses}</p>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-4">
|
||||||
|
<p className="text-xs text-muted-foreground">Erneuerungen ≤ 60 d</p>
|
||||||
|
<p className="mt-1 text-xl font-semibold tabular-nums">{recurring.renewalsDue.length}</p>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-4">
|
||||||
|
<p className="text-xs text-muted-foreground">MRR</p>
|
||||||
|
<p className="mt-1 text-xl font-semibold tabular-nums">{formatEuroCompact(recurring.mrr)}</p>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-4">
|
||||||
|
<p className="text-xs text-muted-foreground">ARR</p>
|
||||||
|
<p className="mt-1 text-xl font-semibold tabular-nums">{formatEuroCompact(recurring.arr)}</p>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Activity tab */}
|
||||||
|
<TabsContent value="activity" className="mt-4 space-y-4">
|
||||||
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-sm">
|
||||||
|
<Activity className="size-3.5 text-muted-foreground" />
|
||||||
|
Aktivität nach Aktionsgruppe (30 Tage)
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ResponsiveContainer width="100%" height={220}>
|
||||||
|
<BarChart data={activity} layout="vertical" margin={{ top: 4, right: 16, bottom: 0, left: 20 }}>
|
||||||
|
<XAxis type="number" tick={{ fontSize: 11, fill: 'var(--muted-foreground)' }} axisLine={false} tickLine={false} />
|
||||||
|
<YAxis type="category" dataKey="group" tick={{ fontSize: 10, fill: 'var(--muted-foreground)' }} axisLine={false} tickLine={false} width={100} />
|
||||||
|
<Tooltip
|
||||||
|
cursor={{ fill: 'var(--muted)', opacity: 0.4 }}
|
||||||
|
contentStyle={{ background: 'var(--popover)', border: '1px solid var(--border)', borderRadius: 'var(--radius)', fontSize: 12 }}
|
||||||
|
formatter={(v: number) => [`${v} Ereignisse`, 'Anzahl']}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="count" radius={[0, 3, 3, 0]}>
|
||||||
|
{activity.map((_, i) => (
|
||||||
|
<Cell key={i} fill="var(--foreground)" fillOpacity={0.4 + (i / Math.max(activity.length, 1)) * 0.6} />
|
||||||
|
))}
|
||||||
|
</Bar>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm">Umsatz- & Aktivitätstrend</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ResponsiveContainer width="100%" height={220}>
|
||||||
|
<LineChart data={revenue} margin={{ top: 4, right: 4, bottom: 0, left: -20 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" vertical={false} />
|
||||||
|
<XAxis dataKey="label" tick={{ fontSize: 11, fill: 'var(--muted-foreground)' }} axisLine={false} tickLine={false} />
|
||||||
|
<YAxis tick={{ fontSize: 11, fill: 'var(--muted-foreground)' }} axisLine={false} tickLine={false} tickFormatter={(v) => formatEuroCompact(v).replace(' €', '')} width={50} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{ background: 'var(--popover)', border: '1px solid var(--border)', borderRadius: 'var(--radius)', fontSize: 12 }}
|
||||||
|
formatter={(v: number) => [formatEuro(v), 'Bezahlt']}
|
||||||
|
/>
|
||||||
|
<Line type="monotone" dataKey="cents" stroke="var(--foreground)" strokeWidth={2} dot={{ fill: 'var(--foreground)', r: 3 }} />
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
<Button variant="ghost" size="sm" className="mt-2 h-7 text-xs" onClick={() => setView('audit')}>
|
||||||
|
Vollständiges Audit-Protokoll →
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MrrRow({ label, cents, total, negative }: { label: string; cents: number; total: number; negative?: boolean }) {
|
||||||
|
const pct = total > 0 ? Math.round((cents / total) * 100) : 0
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-1 flex items-center justify-between text-xs">
|
||||||
|
<span className="text-muted-foreground">{label}</span>
|
||||||
|
<span className={`tabular-nums ${negative ? 'text-muted-foreground' : ''}`}>
|
||||||
|
{negative ? '−' : ''}{formatEuro(cents)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-1.5 w-full overflow-hidden rounded bg-muted">
|
||||||
|
<div className={`h-full rounded ${negative ? 'bg-muted-foreground/40' : 'bg-foreground'}`} style={{ width: `${pct}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,444 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import {
|
||||||
|
Rocket,
|
||||||
|
UserPlus,
|
||||||
|
Building2,
|
||||||
|
Boxes,
|
||||||
|
Mail,
|
||||||
|
Sparkles,
|
||||||
|
Database,
|
||||||
|
ShieldCheck,
|
||||||
|
Check,
|
||||||
|
ChevronRight,
|
||||||
|
ChevronLeft,
|
||||||
|
Loader2,
|
||||||
|
TestTube,
|
||||||
|
Lock,
|
||||||
|
} from 'lucide-react'
|
||||||
|
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 { Switch } from '@/components/ui/switch'
|
||||||
|
import { PageHeader } from '../primitives'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
const STEPS = [
|
||||||
|
{ id: 1, label: 'Admin-Konto', icon: UserPlus, desc: 'Passkey + Instanz-URL' },
|
||||||
|
{ id: 2, label: 'Firmendaten', icon: Building2, desc: 'Rechtsform, USt-IdNr., Bank' },
|
||||||
|
{ id: 3, label: 'Module', icon: Boxes, desc: 'Auswahl & Abhängigkeiten' },
|
||||||
|
{ id: 4, label: 'E-Mail', icon: Mail, desc: 'SMTP / IMAP Test' },
|
||||||
|
{ id: 5, label: 'Integrationen', icon: Sparkles, desc: 'AI, Jina, Search' },
|
||||||
|
{ id: 6, label: 'Seed-Daten', icon: Database, desc: 'Pipelines, Vorlagen, Sätze' },
|
||||||
|
{ id: 7, label: 'Backup & Key', icon: ShieldCheck, desc: 'Verschlüsselung + Escrow' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const MODULE_OPTIONS = [
|
||||||
|
{ id: 'crm', label: 'CRM', desc: 'Firmen, Deals, Pipeline', required: true },
|
||||||
|
{ id: 'leads', label: 'Leads Finder', desc: 'Evidenzbasierte Audits + AI', recommended: true },
|
||||||
|
{ id: 'projects', label: 'Projekte', desc: 'Aufgaben, Zeit, Budgets', recommended: true },
|
||||||
|
{ id: 'licenses', label: 'Lizenzen', desc: 'Ausgestellt + Erneuerungen' },
|
||||||
|
{ id: 'contracts', label: 'Verträge', desc: 'Werkvertrag, AVV, NDA' },
|
||||||
|
{ id: 'expenses', label: 'Ausgaben', desc: 'Belastbar, DATEV' },
|
||||||
|
{ id: 'estimates', label: 'Angebote', desc: 'Quotes → Projekt → Rechnung' },
|
||||||
|
{ id: 'support', label: 'Support', desc: 'Tickets + AI-Summary' },
|
||||||
|
{ id: 'finance', label: 'Finanzen', desc: 'Rechnungen, E-Rechnung' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function SetupWizardView() {
|
||||||
|
const [step, setStep] = useState(1)
|
||||||
|
const [completed, setCompleted] = useState<Set<number>>(new Set())
|
||||||
|
const [mailTesting, setMailTesting] = useState(false)
|
||||||
|
const [mailResult, setMailResult] = useState<{ smtp: { ok: boolean; message: string }; imap: { ok: boolean; message: string } } | null>(null)
|
||||||
|
const [modules, setModules] = useState<Record<string, boolean>>({
|
||||||
|
crm: true, leads: true, projects: true, licenses: true, contracts: true, expenses: true, estimates: true, support: true, finance: true,
|
||||||
|
})
|
||||||
|
const [aiEnabled, setAiEnabled] = useState(true)
|
||||||
|
const [escrowConfirmed, setEscrowConfirmed] = useState(false)
|
||||||
|
const [done, setDone] = useState(false)
|
||||||
|
|
||||||
|
const next = () => {
|
||||||
|
setCompleted((s) => new Set(s).add(step))
|
||||||
|
if (step < 7) setStep(step + 1)
|
||||||
|
else setDone(true)
|
||||||
|
}
|
||||||
|
const back = () => step > 1 && setStep(step - 1)
|
||||||
|
|
||||||
|
const testMail = async () => {
|
||||||
|
setMailTesting(true)
|
||||||
|
const t = toast.loading('SMTP/IMAP-Verbindung wird getestet…')
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/aos/setup/test-mail', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ smtpHost: 'smtp.nordlicht.digital', smtpPort: 587, imapHost: 'imap.nordlicht.digital', imapPort: 993, user: 'mail@nordlicht.digital' }),
|
||||||
|
})
|
||||||
|
if (!r.ok) throw new Error('test failed')
|
||||||
|
const data = await r.json()
|
||||||
|
setMailResult(data)
|
||||||
|
toast.success('Verbindungstest abgeschlossen', { id: t })
|
||||||
|
} catch {
|
||||||
|
toast.error('Test fehlgeschlagen', { id: t })
|
||||||
|
} finally {
|
||||||
|
setMailTesting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (done) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 p-4 md:p-6">
|
||||||
|
<PageHeader title="Setup-Wizard" description="Installation abgeschlossen." />
|
||||||
|
<Card className="mx-auto max-w-2xl">
|
||||||
|
<CardContent className="flex flex-col items-center gap-4 p-10 text-center">
|
||||||
|
<div className="flex size-16 items-center justify-center rounded-full bg-foreground text-background">
|
||||||
|
<Check className="size-8" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h2 className="text-xl font-semibold">AOS ist bereit</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Installation in unter 15 Minuten abgeschlossen. Alle Module aktiviert, E-Mail verbunden, Backup konfiguriert.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid w-full grid-cols-2 gap-3 text-left">
|
||||||
|
<SummaryRow label="Module aktiviert" value={`${Object.values(modules).filter(Boolean).length} / ${MODULE_OPTIONS.length}`} />
|
||||||
|
<SummaryRow label="E-Mail" value={mailResult ? 'Verbunden' : 'Übersprungen'} />
|
||||||
|
<SummaryRow label="AI-Schicht" value={aiEnabled ? 'Aktiv' : 'Deaktiviert'} />
|
||||||
|
<SummaryRow label="Verschlüsselung" value="AES-256-GCM" />
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" onClick={() => { setDone(false); setStep(1); setCompleted(new Set()) }}>
|
||||||
|
Wiederholen
|
||||||
|
</Button>
|
||||||
|
<Button>Zum Dashboard</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 p-4 md:p-6">
|
||||||
|
<PageHeader
|
||||||
|
title="Setup-Wizard"
|
||||||
|
description="„Next, next, done“ — AOS in unter 15 Minuten installiert. Jeder Schritt wird gespeichert, bevor der nächste erscheint."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-4">
|
||||||
|
{/* Stepper */}
|
||||||
|
<Card className="lg:col-span-1">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
{STEPS.map((s) => {
|
||||||
|
const Icon = s.icon
|
||||||
|
const isActive = step === s.id
|
||||||
|
const isDone = completed.has(s.id)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={s.id}
|
||||||
|
onClick={() => setStep(s.id)}
|
||||||
|
className={cn(
|
||||||
|
'flex w-full items-center gap-3 rounded-md p-2 text-left transition',
|
||||||
|
isActive ? 'bg-muted' : 'hover:bg-muted/50'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className={cn(
|
||||||
|
'flex size-8 shrink-0 items-center justify-center rounded-md border',
|
||||||
|
isDone ? 'border-foreground bg-foreground text-background' : isActive ? 'border-foreground/40' : 'border-border'
|
||||||
|
)}>
|
||||||
|
{isDone ? <Check className="size-4" /> : <Icon className="size-4" />}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className={cn('text-xs font-medium', isActive || isDone ? 'text-foreground' : 'text-muted-foreground')}>
|
||||||
|
{s.id}. {s.label}
|
||||||
|
</p>
|
||||||
|
<p className="truncate text-[10px] text-muted-foreground">{s.desc}</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 border-t border-border pt-3">
|
||||||
|
<div className="mb-1 flex items-center justify-between text-[10px] text-muted-foreground">
|
||||||
|
<span>Fortschritt</span>
|
||||||
|
<span>{completed.size}/7</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-1.5 w-full overflow-hidden rounded bg-muted">
|
||||||
|
<div className="h-full rounded bg-foreground transition-all duration-500" style={{ width: `${(completed.size / 7) * 100}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Step content */}
|
||||||
|
<Card className="lg:col-span-3">
|
||||||
|
<CardHeader className="border-b border-border pb-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{(() => {
|
||||||
|
const Icon = STEPS[step - 1].icon
|
||||||
|
return <Icon className="size-5 text-muted-foreground" />
|
||||||
|
})()}
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-base">Schritt {step}: {STEPS[step - 1].label}</CardTitle>
|
||||||
|
<p className="mt-0.5 text-xs text-muted-foreground">{STEPS[step - 1].desc}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4 p-6">
|
||||||
|
{step === 1 && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
<Field label="Name" placeholder="Max Brandt" defaultValue="Max Brandt" />
|
||||||
|
<Field label="E-Mail" placeholder="max@agentur.de" defaultValue="max@nordlicht.digital" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Instanz-URL</Label>
|
||||||
|
<Input defaultValue="https://aos.nordlicht.digital" className="text-sm" />
|
||||||
|
<p className="text-[10px] text-muted-foreground">Wird für E-Mail-Links, Webhooks und das Client-Portal verwendet.</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border border-border bg-muted/20 p-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Lock className="size-4 text-muted-foreground" />
|
||||||
|
<span className="text-xs font-medium">Passkey-Authentifizierung</span>
|
||||||
|
<Badge variant="outline" className="ml-auto text-[10px]">Empfohlen</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-[11px] text-muted-foreground">Better Auth mit Passkeys + Passwort-Fallback. Sessions, Rate-Limits, TOTP optional.</p>
|
||||||
|
<Button size="sm" variant="outline" className="mt-2 h-7 gap-1.5 text-xs">
|
||||||
|
<UserPlus className="size-3.5" />Passkey registrieren
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 2 && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
<Field label="Firmenname" defaultValue="Nordlicht Digital GmbH" />
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Rechtsform</Label>
|
||||||
|
<select className="h-9 w-full rounded border border-input bg-background px-3 text-sm outline-none focus-visible:ring-1 focus-visible:ring-ring">
|
||||||
|
<option>GmbH</option><option>UG (haftungsbeschränkt)</option><option>AG</option><option>e.K.</option><option>GbR</option><option>Einzelunternehmen</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<Field label="USt-IdNr." defaultValue="DE284617302" mono />
|
||||||
|
<Field label="Registergericht" defaultValue="Amtsgericht Hamburg" />
|
||||||
|
<Field label="HRB-Nummer" defaultValue="HRB 142887" mono />
|
||||||
|
<Field label="Sitz" defaultValue="Hamburg" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
<Field label="IBAN" defaultValue="DE89 3704 0044 0532 0130 00" mono />
|
||||||
|
<Field label="BIC" defaultValue="COBADEFFXXX" mono />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||||
|
<Field label="Rechnungspräfix" defaultValue="RE" mono />
|
||||||
|
<Field label="Steuersatz (Standard)" defaultValue="19%" />
|
||||||
|
<Field label="Steuersatz (ermäßigt)" defaultValue="7%" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 3 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Wähle die Module für diese Instanz. In v1 wird „Aktivieren“ via Rebuild ausgeführt (kein Hot-Plug).
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||||
|
{MODULE_OPTIONS.map((m) => (
|
||||||
|
<div key={m.id} className={cn('flex items-center gap-3 rounded-md border p-3', modules[m.id] ? 'border-foreground/30 bg-foreground/5' : 'border-border')}>
|
||||||
|
<Switch
|
||||||
|
checked={modules[m.id]}
|
||||||
|
onCheckedChange={(v) => setModules((s) => ({ ...s, [m.id]: v }))}
|
||||||
|
disabled={m.required}
|
||||||
|
/>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-sm font-medium">{m.label}</span>
|
||||||
|
{m.required && <Badge variant="outline" className="text-[9px]">Pflicht</Badge>}
|
||||||
|
{m.recommended && <Badge variant="secondary" className="text-[9px]">Empfohlen</Badge>}
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-muted-foreground">{m.desc}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border border-dashed border-border p-3 text-[11px] text-muted-foreground">
|
||||||
|
<p className="font-medium text-foreground">Abhängigkeiten</p>
|
||||||
|
<p className="mt-1">CRM ist Basis für Leads, Angebote, Projekte, Lizenzen, Verträge, Ausgaben und Finanzen. Module können jederzeit in „Erweiterungen“ (in)aktiviert werden.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 4 && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
<Field label="SMTP-Host" defaultValue="smtp.nordlicht.digital" />
|
||||||
|
<Field label="SMTP-Port" defaultValue="587" mono />
|
||||||
|
<Field label="IMAP-Host" defaultValue="imap.nordlicht.digital" />
|
||||||
|
<Field label="IMAP-Port" defaultValue="993" mono />
|
||||||
|
<Field label="Benutzer" defaultValue="mail@nordlicht.digital" />
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Passwort</Label>
|
||||||
|
<Input type="password" placeholder="••••••••" className="text-sm" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border border-border p-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs font-medium">Verbindung testen</span>
|
||||||
|
<Button size="sm" variant="outline" className="h-7 gap-1.5 text-xs" onClick={testMail} disabled={mailTesting}>
|
||||||
|
{mailTesting ? <Loader2 className="size-3.5 animate-spin" /> : <TestTube className="size-3.5" />}
|
||||||
|
Test ausführen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{mailResult && (
|
||||||
|
<div className="mt-2 space-y-1.5 text-[11px]">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={cn('size-1.5 rounded-full', mailResult.smtp.ok ? 'bg-foreground' : 'bg-muted-foreground/40')} />
|
||||||
|
<span className="text-muted-foreground">SMTP:</span>
|
||||||
|
<span>{mailResult.smtp.message}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={cn('size-1.5 rounded-full', mailResult.imap.ok ? 'bg-foreground' : 'bg-muted-foreground/40')} />
|
||||||
|
<span className="text-muted-foreground">IMAP:</span>
|
||||||
|
<span>{mailResult.imap.message}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 5 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
AI ist optional und standardmäßig deaktiviert. Ohne API-Keys verlässt kein Datum den Server.
|
||||||
|
</p>
|
||||||
|
<div className="rounded-md border border-border p-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">AI-Schicht aktivieren</p>
|
||||||
|
<p className="text-[11px] text-muted-foreground">Lead-Erklärungen, Ticket-Summary, Antwort-Vorschläge</p>
|
||||||
|
</div>
|
||||||
|
<Switch checked={aiEnabled} onCheckedChange={setAiEnabled} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{aiEnabled && (
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
<Field label="LLM Provider" defaultValue="z-ai-web-dev-sdk" />
|
||||||
|
<Field label="Jina (OCR/Embeddings)" placeholder="API-Key (optional)" />
|
||||||
|
<Field label="Search Provider" placeholder="SerpAPI / Tavily Key" />
|
||||||
|
<Field label="Firecrawl" placeholder="API-Key (optional)" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="rounded-md border border-dashed border-border p-3 text-[11px] text-muted-foreground">
|
||||||
|
<p className="font-medium text-foreground">Guardrails</p>
|
||||||
|
<p className="mt-1">Strukturierte Outputs (Zod-validiert), AI schreibt nie in Finanz-Tabellen, jede Aktion wird mit Prompt-Hash protokolliert. Mensch bestätigt vor jedem Versand.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 6 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Sensible deutsche Standardwerte: Pipeline-Stages, Aufgaben-Workflows, Stundensätze, Vorlagen.
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||||
|
{[
|
||||||
|
'Pipeline-Stages (Lead → Qualifiziert → Angebot → Verhandlung → Gewonnen/Verloren)',
|
||||||
|
'Aufgaben-Workflows (Website, Shop, App, Retainer)',
|
||||||
|
'Stundensätze (Junior 75€, Senior 95€, Lead 110€)',
|
||||||
|
'E-Mail-Vorlagen (Angebot, Rechnung, Mahnung, Willkommen)',
|
||||||
|
'Vertrags-Templates (Werkvertrag, AVV, NDA, SLA)',
|
||||||
|
'Rechnungs-Vorlage (Typst, ZUGFeRD-konform)',
|
||||||
|
'Launch-Checkliste (Impressum, Datenschutz, BFSG, Backups)',
|
||||||
|
'Verlust-Gründe (Budget, Timing, Wettbewerb, Kein Bedarf)',
|
||||||
|
].map((item) => (
|
||||||
|
<div key={item} className="flex items-start gap-2 rounded-md border border-border p-2">
|
||||||
|
<Check className="mt-0.5 size-3.5 shrink-0 text-foreground" />
|
||||||
|
<span className="text-[11px]">{item}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 7 && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="rounded-md border border-border p-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ShieldCheck className="size-4 text-muted-foreground" />
|
||||||
|
<span className="text-sm font-medium">Backup-Ziel</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
<Field label="Backup-Typ" defaultValue="S3-kompatibel" />
|
||||||
|
<Field label="Intervall" defaultValue="Täglich 03:00" />
|
||||||
|
<Field label="Aufbewahrung" defaultValue="30 Tage" />
|
||||||
|
<Field label="Ziel" defaultValue="s3://backups.nordlicht/aos/" mono />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border border-foreground/30 bg-foreground/5 p-3">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<Lock className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium">ENCRYPTION_KEY Escrow bestätigen</p>
|
||||||
|
<p className="mt-1 text-[11px] text-muted-foreground">
|
||||||
|
Der Verschlüsselungs-Key (AES-256-GCM) sichert SMTP, API-Keys und Signier-Schlüssel. Er wird <span className="font-medium">separat vom DB-Backup</span> verwahrt. Ohne ihn sind Secrets unwiederherstellbar.
|
||||||
|
</p>
|
||||||
|
<label className="mt-2 flex items-center gap-2 text-xs">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={escrowConfirmed}
|
||||||
|
onChange={(e) => setEscrowConfirmed(e.target.checked)}
|
||||||
|
className="size-3.5 rounded border-border"
|
||||||
|
/>
|
||||||
|
Ich habe den Key sicher gespeichert (Passwort-Manager / Offline-Medium).
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border border-dashed border-border p-3 text-[11px] text-muted-foreground">
|
||||||
|
<p>Restore-Drill: Backup/Restore wird bei jedem Release geprüft. Empfehlung: ersten Restore-Test innerhalb von 7 Tagen.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
|
{/* Footer nav */}
|
||||||
|
<div className="flex items-center justify-between border-t border-border p-4">
|
||||||
|
<Button variant="ghost" size="sm" onClick={back} disabled={step === 1} className="gap-1.5">
|
||||||
|
<ChevronLeft className="size-4" />Zurück
|
||||||
|
</Button>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{completed.has(step) && <Badge variant="outline" className="gap-1 text-[10px]"><Check className="size-2.5" />Gespeichert</Badge>}
|
||||||
|
<Button size="sm" onClick={next} disabled={step === 7 && !escrowConfirmed} className="gap-1.5">
|
||||||
|
{step === 7 ? 'Abschließen' : 'Weiter & Speichern'}
|
||||||
|
{step === 7 ? <Check className="size-4" /> : <ChevronRight className="size-4" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({ label, placeholder, defaultValue, mono }: { label: string; placeholder?: string; defaultValue?: string; mono?: boolean }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">{label}</Label>
|
||||||
|
<Input placeholder={placeholder} defaultValue={defaultValue} className={cn('text-sm', mono && 'font-mono')} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SummaryRow({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-border p-2.5">
|
||||||
|
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">{label}</p>
|
||||||
|
<p className="mt-0.5 text-sm font-medium">{value}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -7,15 +7,17 @@ export type ViewId =
|
|||||||
| 'dashboard'
|
| 'dashboard'
|
||||||
| 'crm'
|
| 'crm'
|
||||||
| 'leads'
|
| 'leads'
|
||||||
|
| 'estimates'
|
||||||
| 'projects'
|
| 'projects'
|
||||||
|
| 'support'
|
||||||
| 'licenses'
|
| 'licenses'
|
||||||
| 'contracts'
|
| 'contracts'
|
||||||
| 'expenses'
|
| 'expenses'
|
||||||
| 'estimates'
|
|
||||||
| 'support'
|
|
||||||
| 'finance'
|
| 'finance'
|
||||||
| 'audit'
|
| 'audit'
|
||||||
|
| 'reporting'
|
||||||
| 'settings'
|
| 'settings'
|
||||||
|
| 'setup'
|
||||||
|
|
||||||
interface AosState {
|
interface AosState {
|
||||||
view: ViewId
|
view: ViewId
|
||||||
|
|||||||