b6167bcb-7363-4695-9481-addd81de4367

This commit is contained in:
Z User committed 2026-10-26 13:40:52 +00:00
1 parent fdc3b82d10
commit d226dabba3
136 files changed
+13904 -2

No files matched your search

+54
View File
@@ -0,0 +1,54 @@
'use client'
import { useAos } from '@/store/aos'
import { SidebarNav } from './sidebar-nav'
import { TopBar } from './top-bar'
import { Footer } from './footer'
import { CommandMenu } from './command-menu'
import { Sheet, SheetContent } from '@/components/ui/sheet'
import { DashboardView } from './views/dashboard'
import { CrmView } from './views/crm'
import { LeadsView } from './views/leads'
import { ProjectsView } from './views/projects'
import { LicensesView } from './views/licenses'
import { FinanceView } from './views/finance'
import { SettingsView } from './views/settings'
export function AppShell() {
const { view, sidebarOpen, setSidebarOpen } = useAos()
return (
<div className="flex min-h-screen w-full bg-background">
{/* Desktop sidebar */}
<aside className="hidden w-64 shrink-0 border-r border-border md:block">
<div className="sticky top-0 h-screen">
<SidebarNav />
</div>
</aside>
{/* Mobile sidebar */}
<Sheet open={sidebarOpen} onOpenChange={setSidebarOpen}>
<SheetContent side="left" className="w-72 p-0">
<SidebarNav />
</SheetContent>
</Sheet>
{/* Main */}
<div className="flex min-w-0 flex-1 flex-col">
<TopBar />
<main className="flex-1">
{view === 'dashboard' && <DashboardView />}
{view === 'crm' && <CrmView />}
{view === 'leads' && <LeadsView />}
{view === 'projects' && <ProjectsView />}
{view === 'licenses' && <LicensesView />}
{view === 'finance' && <FinanceView />}
{view === 'settings' && <SettingsView />}
</main>
<Footer />
</div>
<CommandMenu />
</div>
)
}
+98
View File
@@ -0,0 +1,98 @@
'use client'
import { useEffect } from 'react'
import { useRouter } from 'next/navigation'
import {
LayoutDashboard,
Users,
Radar,
FolderKanban,
KeyRound,
Receipt,
Settings2,
RotateCcw,
} from 'lucide-react'
import { useAos, type ViewId } from '@/store/aos'
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from '@/components/ui/command'
import { toast } from 'sonner'
const VIEWS: { id: ViewId; label: string; icon: typeof LayoutDashboard; hint: string }[] = [
{ id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard, hint: 'Übersicht & KPIs' },
{ id: 'crm', label: 'CRM', icon: Users, hint: 'Firmen & Pipeline' },
{ id: 'leads', label: 'Leads Finder', icon: Radar, hint: 'Review-Queue' },
{ id: 'projects', label: 'Projekte', icon: FolderKanban, hint: 'Aufgaben & Zeit' },
{ id: 'licenses', label: 'Lizenzen', icon: KeyRound, hint: 'Ausgestellt & Erneuerungen' },
{ id: 'finance', label: 'Finanzen', icon: Receipt, hint: 'Rechnungen & E-Rechnung' },
{ id: 'settings', label: 'Erweiterungen', icon: Settings2, hint: 'Module & Compliance' },
]
export function CommandMenu() {
const { commandOpen, setCommandOpen, setView } = useAos()
const router = useRouter()
// keyboard shortcut cmd+k
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
e.preventDefault()
setCommandOpen(!commandOpen)
}
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [commandOpen, setCommandOpen])
const go = (v: ViewId) => {
setView(v)
setCommandOpen(false)
}
const reseed = async () => {
setCommandOpen(false)
const t = toast.loading('Datenbank wird zurückgesetzt…')
try {
const r = await fetch('/api/aos/seed', { method: 'POST' })
if (!r.ok) throw new Error('reseed failed')
toast.success('Datenbank neu befüllt', { id: t })
setTimeout(() => router.refresh(), 400)
} catch {
toast.error('Reseed fehlgeschlagen', { id: t })
}
}
return (
<CommandDialog open={commandOpen} onOpenChange={setCommandOpen}>
<CommandInput placeholder="Modul, Aktion oder Seite suchen…" />
<CommandList>
<CommandEmpty>Keine Treffer.</CommandEmpty>
<CommandGroup heading="Navigation">
{VIEWS.map((v) => {
const Icon = v.icon
return (
<CommandItem key={v.id} value={`${v.label} ${v.hint}`} onSelect={() => go(v.id)}>
<Icon className="size-4" />
<span>{v.label}</span>
<span className="ml-auto text-xs text-muted-foreground">{v.hint}</span>
</CommandItem>
)
})}
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="Aktionen">
<CommandItem onSelect={reseed} value="demo daten zurücksetzen reseed reset">
<RotateCcw className="size-4" />
<span>Demo-Daten zurücksetzen</span>
</CommandItem>
</CommandGroup>
</CommandList>
</CommandDialog>
)
}
+35
View File
@@ -0,0 +1,35 @@
'use client'
import { ShieldCheck, Server, Database } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
export function Footer() {
const year = new Date().getFullYear()
return (
<footer className="mt-auto border-t border-border bg-background">
<div className="mx-auto flex max-w-[1600px] flex-col gap-3 px-4 py-3.5 text-xs text-muted-foreground sm:flex-row sm:items-center sm:justify-between md:px-6">
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5">
<span className="font-medium text-foreground">Nordlicht Digital GmbH</span>
<span className="hidden sm:inline">·</span>
<span>AOS v1.0.0-dev</span>
<span className="hidden sm:inline">·</span>
<span>© {year}</span>
</div>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5">
<Badge variant="outline" className="gap-1 font-normal">
<ShieldCheck className="size-3" />
GoBD / DSGVO
</Badge>
<span className="flex items-center gap-1">
<Server className="size-3" />
self-hosted
</span>
<span className="flex items-center gap-1">
<Database className="size-3" />
SQLite
</span>
</div>
</div>
</footer>
)
}
+132
View File
@@ -0,0 +1,132 @@
'use client'
import { type LucideIcon } from 'lucide-react'
import { Card } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { cn } from '@/lib/utils'
export function PageHeader({
title,
description,
actions,
}: {
title: string
description?: string
actions?: React.ReactNode
}) {
return (
<div className="flex flex-col gap-3 border-b border-border pb-5 sm:flex-row sm:items-end sm:justify-between">
<div className="space-y-1">
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
{description && <p className="max-w-2xl text-sm text-muted-foreground">{description}</p>}
</div>
{actions && <div className="flex shrink-0 items-center gap-2">{actions}</div>}
</div>
)
}
export function KpiCard({
label,
value,
sub,
icon: Icon,
delta,
tone = 'default',
}: {
label: string
value: React.ReactNode
sub?: string
icon?: LucideIcon
delta?: { value: string; positive: boolean }
tone?: 'default' | 'muted'
}) {
return (
<Card className={cn('gap-0 p-4', tone === 'muted' && 'bg-muted/40')}>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 space-y-1.5">
<p className="truncate text-xs font-medium text-muted-foreground">{label}</p>
<p className="text-2xl font-semibold tracking-tight tabular-nums">{value}</p>
{sub && <p className="text-[11px] text-muted-foreground">{sub}</p>}
</div>
{Icon && (
<div className="flex size-8 shrink-0 items-center justify-center rounded-md border border-border text-muted-foreground">
<Icon className="size-4" />
</div>
)}
</div>
{delta && (
<p className={cn('mt-2 text-[11px] font-medium', delta.positive ? 'text-foreground' : 'text-muted-foreground')}>
{delta.value}
</p>
)}
</Card>
)
}
export function CardSkeletons({ count = 4 }: { count?: number }) {
return (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: count }).map((_, i) => (
<Card key={i} className="p-4">
<Skeleton className="h-3 w-20" />
<Skeleton className="mt-3 h-7 w-28" />
</Card>
))}
</div>
)
}
export function EmptyState({
icon: Icon,
title,
description,
action,
}: {
icon: LucideIcon
title: string
description?: string
action?: React.ReactNode
}) {
return (
<div className="flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-border bg-muted/20 px-6 py-14 text-center">
<div className="flex size-12 items-center justify-center rounded-full border border-border bg-background">
<Icon className="size-5 text-muted-foreground" />
</div>
<div className="space-y-1">
<p className="text-sm font-medium">{title}</p>
{description && <p className="max-w-sm text-xs text-muted-foreground">{description}</p>}
</div>
{action}
</div>
)
}
export function StatusDot({ status }: { status: string }) {
const map: Record<string, string> = {
active: 'bg-foreground',
paid: 'bg-foreground',
won: 'bg-foreground',
done: 'bg-foreground',
approved: 'bg-foreground',
pending: 'bg-muted-foreground/60',
draft: 'bg-muted-foreground/40',
sent: 'bg-muted-foreground/70',
planning: 'bg-muted-foreground/60',
todo: 'bg-muted-foreground/40',
qualified: 'bg-muted-foreground/70',
proposal: 'bg-muted-foreground/70',
negotiation: 'bg-muted-foreground/80',
'in-progress': 'bg-muted-foreground/80',
review: 'bg-muted-foreground/80',
suspended: 'bg-muted-foreground/60',
expired: 'bg-muted-foreground/40',
overdue: 'bg-foreground/80',
lost: 'bg-muted-foreground/40',
rejected: 'bg-muted-foreground/40',
expired_assets: 'bg-muted-foreground/40',
expiring: 'bg-foreground/60',
cancelled: 'bg-muted-foreground/30',
storno: 'bg-muted-foreground/30',
}
return <span className={cn('inline-block size-1.5 rounded-full', map[status] ?? 'bg-muted-foreground/50')} />
}
+150
View File
@@ -0,0 +1,150 @@
'use client'
import {
LayoutDashboard,
Users,
Radar,
FolderKanban,
KeyRound,
Receipt,
Settings2,
Command,
Search,
Bot,
} from 'lucide-react'
import { useAos, type ViewId } from '@/store/aos'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
const NAV: {
group: string
items: { id: ViewId; label: string; icon: typeof LayoutDashboard; permission: string }[]
}[] = [
{
group: 'Übersicht',
items: [{ id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard, permission: '*' }],
},
{
group: 'Vertrieb',
items: [
{ id: 'crm', label: 'CRM', icon: Users, permission: 'crm.company:read' },
{ id: 'leads', label: 'Leads Finder', icon: Radar, permission: 'leads:read' },
],
},
{
group: 'Lieferung',
items: [{ id: 'projects', label: 'Projekte', icon: FolderKanban, permission: 'projects:read' }],
},
{
group: 'Betrieb',
items: [
{ id: 'licenses', label: 'Lizenzen', icon: KeyRound, permission: 'licenses:read' },
{ id: 'finance', label: 'Finanzen', icon: Receipt, permission: 'finance:read' },
],
},
{
group: 'System',
items: [{ id: 'settings', label: 'Erweiterungen', icon: Settings2, permission: 'admin' }],
},
]
export function SidebarNav() {
const { view, setView, setSidebarOpen, setCommandOpen } = useAos()
return (
<div className="flex h-full flex-col bg-sidebar text-sidebar-foreground">
{/* Brand */}
<div className="flex h-16 items-center gap-2.5 px-5 border-b border-sidebar-border">
<div className="flex size-9 items-center justify-center rounded-md bg-primary text-primary-foreground font-bold tracking-tight">
A
</div>
<div className="flex flex-col leading-tight">
<span className="text-sm font-semibold">AOS</span>
<span className="text-[11px] text-muted-foreground">Nordlicht Digital</span>
</div>
</div>
{/* Search trigger */}
<div className="px-3 pt-3">
<button
onClick={() => setCommandOpen(true)}
className="flex w-full items-center gap-2 rounded-md border border-sidebar-border bg-background/50 px-3 py-2 text-sm text-muted-foreground transition hover:bg-background"
>
<Search className="size-3.5" />
<span>Suchen…</span>
<kbd className="ml-auto inline-flex items-center gap-0.5 rounded border border-border bg-muted px-1.5 py-0.5 text-[10px] font-mono">
<Command className="size-2.5" />K
</kbd>
</button>
</div>
{/* Nav */}
<nav className="flex-1 overflow-y-auto px-2 py-3 [scrollbar-width:thin]">
{NAV.map((section) => (
<div key={section.group} className="mb-4">
<p className="px-3 pb-1.5 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/70">
{section.group}
</p>
<div className="space-y-0.5">
{section.items.map((item) => {
const Icon = item.icon
const active = view === item.id
return (
<button
key={item.id}
onClick={() => {
setView(item.id)
setSidebarOpen(false)
}}
className={cn(
'group flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition',
active
? 'bg-sidebar-accent text-sidebar-accent-foreground'
: 'text-muted-foreground hover:bg-sidebar-accent/60 hover:text-sidebar-accent-foreground'
)}
aria-current={active ? 'page' : undefined}
>
<Icon className={cn('size-4 shrink-0', active ? 'text-foreground' : 'text-muted-foreground group-hover:text-foreground')} />
<span className="truncate">{item.label}</span>
{item.id === 'leads' && (
<Badge variant="secondary" className="ml-auto h-5 px-1.5 text-[10px]">
AI
</Badge>
)}
</button>
)
})}
</div>
</div>
))}
</nav>
{/* AI status */}
<div className="mx-3 mb-3 rounded-md border border-sidebar-border bg-background/40 p-3">
<div className="flex items-center gap-2">
<div className="flex size-7 items-center justify-center rounded bg-muted">
<Bot className="size-3.5 text-muted-foreground" />
</div>
<div className="flex flex-col leading-tight">
<span className="text-xs font-medium">AI-Ebene</span>
<span className="text-[10px] text-muted-foreground">Optional · Daten im Server</span>
</div>
</div>
</div>
{/* User */}
<div className="border-t border-sidebar-border px-3 py-3">
<Button variant="ghost" className="h-auto w-full justify-start gap-3 px-2 py-2">
<div className="flex size-8 items-center justify-center rounded-full bg-primary text-primary-foreground text-xs font-semibold">
MB
</div>
<div className="flex flex-col items-start leading-tight">
<span className="text-sm font-medium">Max Brandt</span>
<span className="text-[11px] text-muted-foreground">Owner</span>
</div>
</Button>
</div>
</div>
)
}
+101
View File
@@ -0,0 +1,101 @@
'use client'
import { Menu, Search, Bell, ChevronRight } from 'lucide-react'
import { useAos, type ViewId } from '@/store/aos'
import { ThemeToggle } from '@/components/theme-toggle'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
const TITLES: Record<ViewId, { title: string; crumb: string }> = {
dashboard: { title: 'Dashboard', crumb: 'Übersicht' },
crm: { title: 'CRM', crumb: 'Vertrieb / CRM' },
leads: { title: 'Leads Finder', crumb: 'Vertrieb / Leads' },
projects: { title: 'Projekte', crumb: 'Lieferung / Projekte' },
licenses: { title: 'Lizenzen', crumb: 'Betrieb / Lizenzen' },
finance: { title: 'Finanzen', crumb: 'Betrieb / Finanzen' },
settings: { title: 'Erweiterungen', crumb: 'System / Module' },
}
export function TopBar() {
const { view, setSidebarOpen, setCommandOpen } = useAos()
const meta = TITLES[view]
const crumbParts = meta.crumb.split(' / ')
return (
<header className="sticky top-0 z-30 flex h-16 items-center gap-3 border-b border-border bg-background/80 px-4 backdrop-blur-md md:px-6">
<Button
variant="ghost"
size="icon"
className="md:hidden"
onClick={() => setSidebarOpen(true)}
aria-label="Menü öffnen"
>
<Menu className="size-5" />
</Button>
{/* Breadcrumb */}
<div className="flex min-w-0 items-center gap-1.5 text-sm">
<span className="hidden text-muted-foreground sm:inline">{crumbParts[0]}</span>
{crumbParts.length > 1 && (
<>
<ChevronRight className="hidden size-3.5 text-muted-foreground/50 sm:inline" />
<span className="truncate font-medium">{crumbParts[1]}</span>
</>
)}
</div>
<div className="ml-auto flex items-center gap-1.5">
<Button
variant="ghost"
size="sm"
className="hidden gap-2 text-muted-foreground md:flex"
onClick={() => setCommandOpen(true)}
>
<Search className="size-3.5" />
<span className="text-xs">Suchen</span>
</Button>
<ThemeToggle />
<Button variant="ghost" size="icon" className="relative" aria-label="Benachrichtigungen">
<Bell className="size-4" />
<span className="absolute right-1.5 top-1.5 size-1.5 rounded-full bg-foreground" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="gap-2 px-1.5">
<Avatar className="size-8">
<AvatarFallback className="bg-primary text-primary-foreground text-xs">MB</AvatarFallback>
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel className="flex flex-col">
<span className="text-sm font-medium">Max Brandt</span>
<span className="text-xs text-muted-foreground">max@nordlicht.digital</span>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem className="flex items-center justify-between">
<span>Rolle</span>
<Badge variant="outline" className="text-[10px]">Owner</Badge>
</DropdownMenuItem>
<DropdownMenuItem>Profil</DropdownMenuItem>
<DropdownMenuItem>Sitzungen</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-muted-foreground">Abmelden</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</header>
)
}
+275
View File
@@ -0,0 +1,275 @@
'use client'
import { useState } from 'react'
import { Plus, Building2, Mail, Phone, Globe, MapPin, Users as UsersIcon } 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 { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { Skeleton } from '@/components/ui/skeleton'
import { PageHeader } from '../primitives'
import { formatEuro, formatDate } from '@/lib/format'
import { cn } from '@/lib/utils'
interface CrmData {
companies: (Company & { persons: Person[]; deals: Deal[] })[]
deals: (Deal & { company: Company })[]
persons: (Person & { company: Company | null })[]
pipeline: { stage: string; label: string; deals: (Deal & { company: Company })[] }[]
}
type Company = { id: string; name: string; legalForm: string | null; vatId: string | null; website: string | null; email: string | null; phone: string | null; city: string | null; zip: string | null; street: string | null; source: string | null; tags: string | null }
type Person = { id: string; firstName: string; lastName: string; email: string | null; phone: string | null; role: string | null }
type Deal = { id: string; title: string; valueCents: number; probability: number; stage: string; expectedClose: Date | string | null; lossReason: string | null }
const STAGE_COLORS: Record<string, string> = {
lead: 'bg-muted-foreground/20',
qualified: 'bg-muted-foreground/35',
proposal: 'bg-muted-foreground/55',
negotiation: 'bg-muted-foreground/75',
won: 'bg-foreground',
lost: 'bg-muted-foreground/15',
}
export function CrmView() {
const { data, loading } = useFetch<CrmData>('/api/aos/crm')
if (loading || !data) {
return (
<div className="space-y-6 p-4 md:p-6">
<Skeleton className="h-9 w-32" />
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-64" />
))}
</div>
</div>
)
}
const totalPipeline = data.deals.filter((d) => d.stage !== 'lost' && d.stage !== 'won').reduce((s, d) => s + d.valueCents, 0)
const wonTotal = data.deals.filter((d) => d.stage === 'won').reduce((s, d) => s + d.valueCents, 0)
return (
<div className="space-y-6 p-4 md:p-6">
<PageHeader
title="CRM"
description="Firmen, Ansprechpartner und Deal-Pipeline. Konfigurierbare Stages, DSGVO-Konsens-Protokoll pro Kontakt."
actions={
<Button size="sm">
<Plus className="size-4" />
Firma
</Button>
}
/>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<Card className="p-4">
<p className="text-xs text-muted-foreground">Firmen</p>
<p className="mt-1 text-2xl font-semibold tabular-nums">{data.companies.length}</p>
</Card>
<Card className="p-4">
<p className="text-xs text-muted-foreground">Ansprechpartner</p>
<p className="mt-1 text-2xl font-semibold tabular-nums">{data.persons.length}</p>
</Card>
<Card className="p-4">
<p className="text-xs text-muted-foreground">Offene Pipeline</p>
<p className="mt-1 text-2xl font-semibold tabular-nums">{formatEuro(totalPipeline)}</p>
</Card>
<Card className="p-4">
<p className="text-xs text-muted-foreground">Gewonnen</p>
<p className="mt-1 text-2xl font-semibold tabular-nums">{formatEuro(wonTotal)}</p>
</Card>
</div>
<Tabs defaultValue="pipeline">
<TabsList>
<TabsTrigger value="pipeline">Pipeline</TabsTrigger>
<TabsTrigger value="companies">Firmen</TabsTrigger>
<TabsTrigger value="persons">Ansprechpartner</TabsTrigger>
</TabsList>
<TabsContent value="pipeline" className="mt-4">
<PipelineBoard pipeline={data.pipeline} />
</TabsContent>
<TabsContent value="companies" className="mt-4">
<CompaniesTable companies={data.companies} />
</TabsContent>
<TabsContent value="persons" className="mt-4">
<PersonsTable persons={data.persons} />
</TabsContent>
</Tabs>
</div>
)
}
function PipelineBoard({ pipeline }: { pipeline: CrmData['pipeline'] }) {
return (
<div className="flex gap-3 overflow-x-auto pb-4 [scrollbar-width:thin]">
{pipeline.map((col) => {
const value = col.deals.reduce((s, d) => s + d.valueCents, 0)
return (
<div key={col.stage} className="flex w-72 shrink-0 flex-col rounded-lg border border-border bg-muted/20">
<div className="flex items-center justify-between border-b border-border px-3 py-2.5">
<div className="flex items-center gap-2">
<span className={cn('size-1.5 rounded-full', STAGE_COLORS[col.stage])} />
<span className="text-xs font-semibold">{col.label}</span>
</div>
<span className="text-[10px] text-muted-foreground">{col.deals.length}</span>
</div>
{value > 0 && (
<div className="px-3 py-1.5 text-[10px] text-muted-foreground">
{formatEuro(value)}
</div>
)}
<div className="flex-1 space-y-2 overflow-y-auto p-2 [scrollbar-width:thin]" style={{ maxHeight: '60vh' }}>
{col.deals.map((d) => (
<Card key={d.id} className="cursor-pointer p-3 transition hover:border-foreground/30 hover:shadow-sm">
<p className="text-xs font-medium leading-snug">{d.title}</p>
<p className="mt-1 truncate text-[11px] text-muted-foreground">{d.company.name}</p>
<div className="mt-2 flex items-center justify-between">
<span className="text-xs font-semibold tabular-nums">{formatEuro(d.valueCents)}</span>
<Badge variant="outline" className="text-[10px]">{d.probability}%</Badge>
</div>
{d.expectedClose && (
<p className="mt-1.5 text-[10px] text-muted-foreground">
⟶ {formatDate(d.expectedClose)}
</p>
)}
{d.lossReason && (
<p className="mt-1.5 text-[10px] text-muted-foreground/70">
Verloren: {d.lossReason}
</p>
)}
</Card>
))}
{col.deals.length === 0 && (
<div className="rounded border border-dashed border-border py-6 text-center text-[11px] text-muted-foreground">
Leer
</div>
)}
</div>
</div>
)
})}
</div>
)
}
function CompaniesTable({ companies }: { companies: CrmData['companies'] }) {
const [q, setQ] = useState('')
const filtered = companies.filter(
(c) => c.name.toLowerCase().includes(q.toLowerCase()) || (c.city ?? '').toLowerCase().includes(q.toLowerCase())
)
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-3">
<CardTitle className="text-sm">Firmen ({filtered.length})</CardTitle>
<input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Filtern…"
className="h-8 w-40 rounded border border-input bg-background px-2 text-xs outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
</CardHeader>
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="border-y border-border bg-muted/40 text-left text-[11px] uppercase tracking-wide text-muted-foreground">
<tr>
<th className="px-4 py-2 font-medium">Firma</th>
<th className="px-4 py-2 font-medium">Rechtsform</th>
<th className="px-4 py-2 font-medium">Ort</th>
<th className="px-4 py-2 font-medium">USt-IdNr.</th>
<th className="px-4 py-2 font-medium">Kontakte</th>
<th className="px-4 py-2 font-medium">Deals</th>
<th className="px-4 py-2 font-medium">Tags</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{filtered.map((c) => (
<tr key={c.id} className="transition hover:bg-muted/40">
<td className="px-4 py-2.5">
<div className="flex items-center gap-2">
<Building2 className="size-3.5 text-muted-foreground" />
<div className="flex flex-col">
<span className="font-medium">{c.name}</span>
{c.website && (
<a href={`https://${c.website}`} target="_blank" rel="noopener noreferrer" className="flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground">
<Globe className="size-3" />{c.website}
</a>
)}
</div>
</div>
</td>
<td className="px-4 py-2.5">
{c.legalForm && <Badge variant="outline" className="text-[10px]">{c.legalForm}</Badge>}
</td>
<td className="px-4 py-2.5">
{c.city && (
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<MapPin className="size-3" />{c.zip} {c.city}
</span>
)}
</td>
<td className="px-4 py-2.5 font-mono text-[11px] text-muted-foreground">{c.vatId ?? '—'}</td>
<td className="px-4 py-2.5">
<span className="flex items-center gap-1 text-xs">
<UsersIcon className="size-3 text-muted-foreground" />{c.persons.length}
</span>
</td>
<td className="px-4 py-2.5 text-xs">{c.deals.length}</td>
<td className="px-4 py-2.5">
<div className="flex flex-wrap gap-1">
{c.tags?.split(',').map((t) => (
<Badge key={t} variant="secondary" className="text-[10px]">{t.trim()}</Badge>
))}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
)
}
function PersonsTable({ persons }: { persons: CrmData['persons'] }) {
return (
<Card>
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="border-y border-border bg-muted/40 text-left text-[11px] uppercase tracking-wide text-muted-foreground">
<tr>
<th className="px-4 py-2 font-medium">Name</th>
<th className="px-4 py-2 font-medium">Firma</th>
<th className="px-4 py-2 font-medium">Rolle</th>
<th className="px-4 py-2 font-medium">Kontakt</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{persons.map((p) => (
<tr key={p.id} className="transition hover:bg-muted/40">
<td className="px-4 py-2.5 font-medium">{p.firstName} {p.lastName}</td>
<td className="px-4 py-2.5 text-xs text-muted-foreground">{p.company?.name ?? '—'}</td>
<td className="px-4 py-2.5 text-xs">{p.role ?? '—'}</td>
<td className="px-4 py-2.5">
<div className="flex flex-col gap-0.5 text-[11px] text-muted-foreground">
{p.email && <span className="flex items-center gap-1"><Mail className="size-3" />{p.email}</span>}
{p.phone && <span className="flex items-center gap-1"><Phone className="size-3" />{p.phone}</span>}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
)
}
+276
View File
@@ -0,0 +1,276 @@
'use client'
import {
TrendingUp,
Radar,
FolderKanban,
KeyRound,
Receipt,
Users,
AlertTriangle,
ArrowUpRight,
Activity,
ShieldCheck,
} 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 { KpiCard, PageHeader, CardSkeletons } from '../primitives'
import { formatEuro, formatEuroCompact, formatRelative } from '@/lib/format'
import { useAos } from '@/store/aos'
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis, Cell } from 'recharts'
interface DashboardData {
kpis: {
pipelineValue: number
wonValue: number
pendingLeads: number
acceptRate: number
openProjects: number
activeLicenses: number
expiringAssets: number
outstandingInvoices: number
paidThisYear: number
auditEntries: number
enabledModules: number
totalModules: number
teamMembers: number
}
funnel: { stage: string; label: string; count: number; valueCents: number }[]
revenue: { label: string; cents: number }[]
audit: { id: string; actor: string | null; action: string; entity: string; entityId: string; after: string | null; createdAt: string }[]
modules: { id: string; enabled: boolean; version: string }[]
users: { id: string; name: string; role: string }[]
}
export function DashboardView() {
const { data, loading } = useFetch<DashboardData>('/api/aos/dashboard')
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-48" />
<CardSkeletons count={4} />
</div>
)
}
const { kpis, funnel, revenue, audit, modules, users } = data
const maxFunnelValue = Math.max(...funnel.map((f) => f.valueCents), 1)
return (
<div className="space-y-6 p-4 md:p-6">
<PageHeader
title="Dashboard"
description="Agentur-Übersicht — Pipeline, Leads, aktive Projekte, Lizenzen und Finanzen in einer Ansicht."
actions={
<Button variant="outline" size="sm" onClick={() => setView('leads')}>
<Radar className="size-4" />
Leads prüfen
</Button>
}
/>
{/* KPI grid */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<KpiCard
label="Offene Pipeline"
value={formatEuroCompact(kpis.pipelineValue)}
sub={`${funnel.reduce((s, f) => s + f.count, 0)} Deals offen`}
icon={TrendingUp}
delta={{ value: '↑ 12 % vs. Vormonat', positive: true }}
/>
<KpiCard
label="Gewonnene Deals (YTD)"
value={formatEuroCompact(kpis.wonValue)}
sub="Ausgeführt in diesem Jahr"
icon={ArrowUpRight}
/>
<KpiCard
label="Leads in Review"
value={kpis.pendingLeads}
sub={`Accept-Rate: ${kpis.acceptRate} %`}
icon={Radar}
/>
<KpiCard
label="Aktive Projekte"
value={kpis.openProjects}
sub="In Planung, Aktiv oder Review"
icon={FolderKanban}
/>
<KpiCard
label="Aktive Lizenzen"
value={kpis.activeLicenses}
sub={`${kpis.expiringAssets} Erneuerungen fällig`}
icon={KeyRound}
/>
<KpiCard
label="Offene Forderungen"
value={formatEuroCompact(kpis.outstandingInvoices)}
sub="Versendet & überfällig"
icon={Receipt}
/>
<KpiCard
label="Bezahlt (YTD)"
value={formatEuroCompact(kpis.paidThisYear)}
sub="Beglichene Rechnungen"
icon={TrendingUp}
/>
<KpiCard
label="Team"
value={kpis.teamMembers}
sub={`${kpis.enabledModules}/${kpis.totalModules} Module aktiv`}
icon={Users}
/>
</div>
{/* Charts row */}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-5">
{/* Pipeline funnel */}
<Card className="lg:col-span-3">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Pipeline nach Phase</CardTitle>
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={() => setView('crm')}>
Zum CRM
</Button>
</CardHeader>
<CardContent className="pt-2">
<div className="space-y-2.5">
{funnel.filter((f) => f.stage !== 'lost').map((f) => (
<div key={f.stage} className="group">
<div className="mb-1 flex items-center justify-between text-xs">
<span className="font-medium">{f.label}</span>
<span className="text-muted-foreground">
{f.count} Deals · {formatEuroCompact(f.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: `${(f.valueCents / maxFunnelValue) * 100}%` }}
/>
</div>
</div>
))}
</div>
</CardContent>
</Card>
{/* Revenue chart */}
<Card className="lg:col-span-2">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Bezahlter Umsatz (6 Monate)</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={180}>
<BarChart data={revenue} margin={{ top: 4, right: 4, bottom: 0, left: -20 }}>
<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
cursor={{ fill: 'var(--muted)', opacity: 0.4 }}
contentStyle={{
background: 'var(--popover)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius)',
fontSize: 12,
}}
formatter={(v: number) => [formatEuro(v), 'Umsatz']}
/>
<Bar dataKey="cents" radius={[3, 3, 0, 0]}>
{revenue.map((_, i) => (
<Cell key={i} fill="var(--foreground)" fillOpacity={0.4 + (i / revenue.length) * 0.6} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
</div>
{/* Bottom row */}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
{/* Recent audit activity */}
<Card className="lg:col-span-2">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="flex items-center gap-2 text-sm font-medium">
<Activity className="size-3.5 text-muted-foreground" />
Audit-Protokoll (Hash-Kette)
</CardTitle>
<Badge variant="outline" className="gap-1 font-normal">
<ShieldCheck className="size-3" />
GoBD-konform
</Badge>
</CardHeader>
<CardContent className="pt-0">
<div className="divide-y divide-border">
{audit.length === 0 && (
<p className="py-6 text-center text-xs text-muted-foreground">Keine Ereignisse.</p>
)}
{audit.slice(0, 7).map((a) => (
<div key={a.id} className="flex items-center gap-3 py-2.5">
<code className="rounded bg-muted px-1.5 py-0.5 text-[10px] font-mono text-muted-foreground">
{a.action}
</code>
<div className="min-w-0 flex-1">
<p className="truncate text-xs">
<span className="text-muted-foreground">{a.entity}</span>{' '}
<span className="font-mono">{a.entityId.slice(0, 8)}</span>
</p>
</div>
<span className="shrink-0 text-[11px] text-muted-foreground">
{a.actor ?? 'system'} · {formatRelative(a.createdAt)}
</span>
</div>
))}
</div>
</CardContent>
</Card>
{/* Team / modules */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Team & Rollen</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<div className="space-y-2">
{users.filter((u) => u.role !== 'client').slice(0, 6).map((u) => (
<div key={u.id} className="flex items-center gap-3">
<div className="flex size-7 items-center justify-center rounded-full bg-muted text-[10px] font-semibold">
{u.name.split(' ').map((n) => n[0]).join('').slice(0, 2)}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-medium">{u.name}</p>
</div>
<Badge variant="outline" className="text-[10px] capitalize">{u.role}</Badge>
</div>
))}
</div>
<div className="mt-4 flex items-center justify-between border-t border-border pt-3">
<span className="flex items-center gap-1.5 text-[11px] text-muted-foreground">
<AlertTriangle className="size-3" />
{modules.filter((m) => !m.enabled).length} Module deaktiviert
</span>
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={() => setView('settings')}>
Verwalten
</Button>
</div>
</CardContent>
</Card>
</div>
</div>
)
}
+247
View File
@@ -0,0 +1,247 @@
'use client'
import {
Plus,
Receipt,
FileCheck2,
AlertTriangle,
Download,
FileText,
Euro,
Clock,
} 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 { PageHeader } from '../primitives'
import { formatEuro, formatDate, daysUntil } from '@/lib/format'
import { cn } from '@/lib/utils'
interface Invoice {
id: string
number: string
status: string
type: string
issueDate: string
dueDate: string
paidDate: string | null
netCents: number
taxBp: number
grossCents: number
zugferd: boolean
xrechnung: boolean
datevExported: boolean
company: { id: string; name: string }
lines: { id: string; description: string; quantity: number; unitCents: number; totalCents: number }[]
}
interface FinanceData {
invoices: Invoice[]
stats: {
total: number
draft: number
sent: number
paid: number
overdue: number
zugferdCount: number
xrechnungCount: number
datevCount: number
outstandingCents: number
}
}
const STATUS_META: Record<string, { label: string; cls: string }> = {
draft: { label: 'Entwurf', cls: 'bg-muted text-foreground' },
sent: { label: 'Versendet', cls: 'bg-foreground/70 text-background' },
paid: { label: 'Bezahlt', cls: 'bg-foreground text-background' },
overdue: { label: 'Überfällig', cls: 'bg-foreground text-background' },
cancelled: { label: 'Storniert', cls: 'bg-muted text-muted-foreground' },
storno: { label: 'Storno', cls: 'bg-muted text-muted-foreground' },
}
const TYPE_LABELS: Record<string, string> = {
final: 'Schlussrechnung',
partial: 'Abschlag',
recurring: 'Wiederkehrend',
credit: 'Gutschrift',
}
export function FinanceView() {
const { data, loading } = useFetch<FinanceData>('/api/aos/finance')
if (loading || !data) {
return (
<div className="space-y-6 p-4 md:p-6">
<Skeleton className="h-9 w-32" />
<Skeleton className="h-48" />
</div>
)
}
const { invoices, stats } = data
return (
<div className="space-y-6 p-4 md:p-6">
<PageHeader
title="Finanzen"
description="Rechnungen mit lückenloser Nummerierung, E-Rechnung (ZUGFeRD/XRechnung), Mahnwesen und DATEV-Export."
actions={
<Button size="sm">
<Plus className="size-4" />
Rechnung
</Button>
}
/>
<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 Forderungen</p>
<Euro className="size-3.5 text-muted-foreground" />
</div>
<p className="mt-1 text-2xl font-semibold tabular-nums">{formatEuro(stats.outstandingCents)}</p>
</Card>
<Card className="p-4">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">Rechnungen gesamt</p>
<Receipt className="size-3.5 text-muted-foreground" />
</div>
<p className="mt-1 text-2xl font-semibold tabular-nums">{stats.total}</p>
<p className="text-[10px] text-muted-foreground">{stats.draft} Entwurf · {stats.overdue} überfällig</p>
</Card>
<Card className="p-4">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">ZUGFeRD / XRechnung</p>
<FileCheck2 className="size-3.5 text-muted-foreground" />
</div>
<p className="mt-1 text-2xl font-semibold tabular-nums">{stats.zugferdCount} / {stats.xrechnungCount}</p>
<p className="text-[10px] text-muted-foreground">E-Rechnung-konform</p>
</Card>
<Card className="p-4">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">DATEV exportiert</p>
<Download className="size-3.5 text-muted-foreground" />
</div>
<p className="mt-1 text-2xl font-semibold tabular-nums">{stats.datevCount}</p>
</Card>
</div>
{/* Compliance notice */}
<div className="flex flex-col gap-2 rounded-md border border-border bg-muted/30 p-3 text-xs sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-start gap-2">
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
<div>
<p className="font-medium">E-Rechnung-Pflicht (ab 2025 Empfang, ab 2027/2028 Versand je nach Umsatz).</p>
<p className="text-muted-foreground">Validierung gem. EN 16931 über <code className="rounded bg-muted px-1 py-0.5 font-mono text-[10px]">@e-invoice-eu/core</code>. GoBD: Rechnungen 8 Jahre, Geschäftsbriefe 6 Jahre (mit Steuerberater bestätigen).</p>
</div>
</div>
</div>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Rechnungen</CardTitle>
</CardHeader>
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="border-y border-border bg-muted/40 text-left text-[11px] uppercase tracking-wide text-muted-foreground">
<tr>
<th className="px-4 py-2 font-medium">Nr.</th>
<th className="px-4 py-2 font-medium">Kunde</th>
<th className="px-4 py-2 font-medium">Typ</th>
<th className="px-4 py-2 font-medium">Datum</th>
<th className="px-4 py-2 font-medium">Fällig</th>
<th className="px-4 py-2 font-medium text-right">Netto</th>
<th className="px-4 py-2 font-medium text-right">Brutto</th>
<th className="px-4 py-2 font-medium">E-Rechnung</th>
<th className="px-4 py-2 font-medium">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{invoices.map((inv) => {
const st = STATUS_META[inv.status] ?? STATUS_META.draft
const dueDays = daysUntil(inv.dueDate)
return (
<tr key={inv.id} className="transition hover:bg-muted/40">
<td className="px-4 py-2.5">
<code className="font-mono text-[11px]">{inv.number}</code>
</td>
<td className="px-4 py-2.5 text-xs">{inv.company.name}</td>
<td className="px-4 py-2.5 text-xs text-muted-foreground">{TYPE_LABELS[inv.type] ?? inv.type}</td>
<td className="px-4 py-2.5 text-xs">{formatDate(inv.issueDate)}</td>
<td className="px-4 py-2.5 text-xs">
{formatDate(inv.dueDate)}
{inv.status === 'sent' && dueDays < 0 && (
<span className="ml-1 text-[10px] text-foreground">({Math.abs(dueDays)} d überfällig)</span>
)}
</td>
<td className="px-4 py-2.5 text-right text-xs tabular-nums">{formatEuro(inv.netCents)}</td>
<td className="px-4 py-2.5 text-right text-xs font-semibold tabular-nums">{formatEuro(inv.grossCents)}</td>
<td className="px-4 py-2.5">
<div className="flex gap-1">
{inv.zugferd && (
<Badge variant="outline" className="text-[9px]">ZUGFeRD</Badge>
)}
{inv.xrechnung && (
<Badge variant="outline" className="text-[9px]">XRechnung</Badge>
)}
{inv.datevExported && (
<Badge variant="secondary" className="text-[9px]">DATEV</Badge>
)}
{!inv.zugferd && !inv.xrechnung && !inv.datevExported && (
<span className="text-[10px] text-muted-foreground">—</span>
)}
</div>
</td>
<td className="px-4 py-2.5">
<Badge variant="outline" className={cn('text-[10px]', st.cls)}>
{st.label}
</Badge>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</CardContent>
</Card>
{/* Gapless numbering + finalize info */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<Card>
<CardContent className="p-4">
<div className="flex items-start gap-3">
<div className="flex size-9 items-center justify-center rounded-md border border-border">
<FileText className="size-4 text-muted-foreground" />
</div>
<div className="flex-1">
<p className="text-sm font-medium">Lückenlose Nummerierung</p>
<p className="mt-1 text-xs text-muted-foreground">
RE-2026-0042 → RE-2026-0043 … GoBD-konform, kein Löschen, Korrekturen als Storno (neue Zeile).
Finalisierungs-Sperre verhindert Nachträgliches Editieren.
</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-start gap-3">
<div className="flex size-9 items-center justify-center rounded-md border border-border">
<Clock className="size-4 text-muted-foreground" />
</div>
<div className="flex-1">
<p className="text-sm font-medium">Time-to-Invoice: 0 manuelle Schritte</p>
<p className="mt-1 text-xs text-muted-foreground">
Freigegebene Zeiteinträge werden direkt zu Rechnungspositionen. Quote → Projekt-Budget → Rechnungsplan → DATEV-Export.
</p>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
)
}
+448
View File
@@ -0,0 +1,448 @@
'use client'
import { useState } from 'react'
import {
Radar,
Check,
X,
Clock,
Sparkles,
ShieldAlert,
ExternalLink,
MapPin,
CheckCircle2,
AlertTriangle,
XCircle,
Loader2,
Filter,
} 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 { ScrollArea } from '@/components/ui/scroll-area'
import { PageHeader } from '../primitives'
import { formatEuro, formatRelative } from '@/lib/format'
import { cn } from '@/lib/utils'
import { toast } from 'sonner'
interface EvidenceItem {
id: string
check: string
status: 'pass' | 'warn' | 'fail'
detail: string
}
interface Lead {
id: string
companyName: string
website: string | null
city: string | null
branch: string | null
score: number
scoreLabel: string | null
evidence: EvidenceItem[]
explanation: string | null
suggestedAngle: string | null
status: string
reviewReason: string | null
source: string | null
radiusKm: number | null
createdAt: string
reviewedAt: string | null
}
interface LeadsData {
leads: Lead[]
}
const STATUS_META: Record<string, { label: string; cls: string }> = {
pending: { label: 'In Review', cls: 'border-border bg-muted/40' },
approved: { label: 'Genehmigt', cls: 'border-foreground/30 bg-foreground/5' },
rejected: { label: 'Abgelehnt', cls: 'border-border bg-muted/20 opacity-70' },
snoozed: { label: 'Zurückgestellt', cls: 'border-border bg-muted/20' },
converted: { label: 'Konvertiert', cls: 'border-foreground/30 bg-foreground/5' },
}
const SCORE_LABELS: Record<string, string> = {
'new-site': 'Neue Website',
redesign: 'Redesign',
seo: 'SEO',
aeo: 'AEO/GEO',
shop: 'Shop',
maintenance: 'Wartung',
accessibility: 'Barrierefreiheit',
}
function scoreColor(score: number) {
if (score >= 75) return 'bg-foreground text-background'
if (score >= 55) return 'bg-foreground/70 text-background'
if (score >= 40) return 'bg-foreground/45 text-background'
return 'bg-muted-foreground/30 text-foreground'
}
export function LeadsView() {
const { data, loading, refetch } = useFetch<LeadsData>('/api/aos/leads')
const [selectedId, setSelectedId] = useState<string | null>(null)
const [filter, setFilter] = useState<string>('all')
const leads = data?.leads ?? []
const filtered = filter === 'all' ? leads : leads.filter((l) => l.status === filter)
const selected = leads.find((l) => l.id === selectedId) ?? filtered[0] ?? null
const review = async (id: string, status: string, reason?: string) => {
const t = toast.loading('Lead wird aktualisiert…')
try {
const r = await fetch(`/api/aos/leads/${id}`, {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ status, reviewReason: reason }),
})
if (!r.ok) throw new Error('update failed')
toast.success(status === 'approved' ? 'Lead genehmigt' : status === 'rejected' ? 'Lead abgelehnt' : 'Lead aktualisiert', { id: t })
refetch()
} catch {
toast.error('Aktualisierung fehlgeschlagen', { id: t })
}
}
if (loading || !data) {
return (
<div className="space-y-6 p-4 md:p-6">
<Skeleton className="h-9 w-40" />
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
<Skeleton className="h-96 lg:col-span-1" />
<Skeleton className="h-96 lg:col-span-2" />
</div>
</div>
)
}
const counts = {
pending: leads.filter((l) => l.status === 'pending').length,
approved: leads.filter((l) => l.status === 'approved').length,
rejected: leads.filter((l) => l.status === 'rejected').length,
}
const acceptRate = counts.approved + counts.rejected > 0
? Math.round((counts.approved / (counts.approved + counts.rejected)) * 100)
: 0
return (
<div className="space-y-6 p-4 md:p-6">
<PageHeader
title="Leads Finder"
description="Evidenzbasierte Website-Audits statt geratener KI-Vorschläge. Jeder Score beruht auf gespeicherten Prüfungen — der LLM liefert nur die Erklärung."
actions={
<div className="flex items-center gap-2">
<Badge variant="outline" className="gap-1 font-normal">
<ShieldAlert className="size-3" />
UWG § 7 Compliance-Gate
</Badge>
<Button size="sm" variant="outline">
<Radar className="size-4" />
Neue Suche
</Button>
</div>
}
/>
{/* Quality loop stats */}
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<Card className="p-4">
<p className="text-xs text-muted-foreground">In Review</p>
<p className="mt-1 text-2xl font-semibold tabular-nums">{counts.pending}</p>
</Card>
<Card className="p-4">
<p className="text-xs text-muted-foreground">Genehmigt</p>
<p className="mt-1 text-2xl font-semibold tabular-nums">{counts.approved}</p>
</Card>
<Card className="p-4">
<p className="text-xs text-muted-foreground">Abgelehnt</p>
<p className="mt-1 text-2xl font-semibold tabular-nums">{counts.rejected}</p>
</Card>
<Card className="p-4">
<p className="text-xs text-muted-foreground">Accept-Rate</p>
<p className="mt-1 text-2xl font-semibold tabular-nums">
{acceptRate}<span className="ml-0.5 text-sm text-muted-foreground">%</span>
</p>
<p className="mt-0.5 text-[10px] text-muted-foreground">Ziel: ≥ 40 %</p>
</Card>
</div>
{/* Compliance banner */}
<div className="flex flex-col gap-2 rounded-md border border-border bg-muted/30 p-3 text-xs sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-start gap-2">
<ShieldAlert className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
<div>
<p className="font-medium">Kein automatisierter Cold-Email-Versand.</p>
<p className="text-muted-foreground">
B2B-E-Mail-Werbung setzt nach § 7 UWG vorab Zustimmung voraus. Das Modul liefert Recherche und Entwürfe — der Mensch entscheidet über den Kanal.
</p>
</div>
</div>
<Badge variant="outline" className="shrink-0 font-normal">Suppression-Liste aktiv</Badge>
</div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-5">
{/* Lead list */}
<div className="lg:col-span-2">
<Card className="overflow-hidden">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm">Review-Queue</CardTitle>
<div className="flex items-center gap-1">
<Filter className="size-3 text-muted-foreground" />
<select
value={filter}
onChange={(e) => setFilter(e.target.value)}
className="h-7 rounded border border-input bg-background px-1.5 text-[11px] outline-none"
>
<option value="all">Alle</option>
<option value="pending">In Review</option>
<option value="approved">Genehmigt</option>
<option value="rejected">Abgelehnt</option>
</select>
</div>
</CardHeader>
<ScrollArea className="h-[70vh]">
<div className="space-y-1.5 p-2">
{filtered.map((l) => {
const meta = STATUS_META[l.status] ?? STATUS_META.pending
return (
<button
key={l.id}
onClick={() => setSelectedId(l.id)}
className={cn(
'w-full rounded-md border p-3 text-left transition hover:border-foreground/40',
meta.cls,
selected?.id === l.id && 'ring-1 ring-foreground/40'
)}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate text-sm font-medium">{l.companyName}</p>
<p className="flex items-center gap-1 truncate text-[11px] text-muted-foreground">
{l.website && <ExternalLink className="size-3" />}
{l.website ?? l.city ?? '—'}
</p>
</div>
<div className={cn('flex size-9 shrink-0 flex-col items-center justify-center rounded text-xs font-bold tabular-nums', scoreColor(l.score))}>
{l.score}
</div>
</div>
<div className="mt-2 flex flex-wrap items-center gap-1.5">
{l.scoreLabel && SCORE_LABELS[l.scoreLabel] && (
<Badge variant="secondary" className="text-[10px]">{SCORE_LABELS[l.scoreLabel]}</Badge>
)}
{l.city && (
<span className="flex items-center gap-0.5 text-[10px] text-muted-foreground">
<MapPin className="size-2.5" />{l.city}
</span>
)}
{l.status !== 'pending' && (
<Badge variant="outline" className="ml-auto text-[10px]">{meta.label}</Badge>
)}
</div>
</button>
)
})}
{filtered.length === 0 && (
<div className="py-10 text-center text-xs text-muted-foreground">
Keine Leads in diesem Filter.
</div>
)}
</div>
</ScrollArea>
</Card>
</div>
{/* Detail panel */}
<div className="lg:col-span-3">
{selected ? (
<LeadDetail lead={selected} onReview={review} onUpdated={refetch} />
) : (
<Card className="flex h-[70vh] items-center justify-center">
<p className="text-sm text-muted-foreground">Wähle einen Lead aus der Queue.</p>
</Card>
)}
</div>
</div>
</div>
)
}
function LeadDetail({ lead, onReview, onUpdated }: { lead: Lead; onReview: (id: string, status: string, reason?: string) => void; onUpdated?: () => void }) {
const [generating, setGenerating] = useState(false)
const meta = STATUS_META[lead.status] ?? STATUS_META.pending
const failCount = lead.evidence.filter((e) => e.status === 'fail').length
const warnCount = lead.evidence.filter((e) => e.status === 'warn').length
const passCount = lead.evidence.filter((e) => e.status === 'pass').length
const generate = async () => {
setGenerating(true)
const t = toast.loading('LLM erzeugt evidenzbasierte Erklärung…')
try {
const r = await fetch('/api/aos/leads/explain', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ leadId: lead.id }),
})
if (!r.ok) throw new Error('explain failed')
toast.success('Erklärung generiert — jede Behauptung verweist auf Evidence.', { id: t })
onUpdated?.()
window.dispatchEvent(new CustomEvent('aos-lead-updated'))
} catch {
toast.error('Generierung fehlgeschlagen', { id: t })
} finally {
setGenerating(false)
}
}
return (
<Card className="flex h-[70vh] flex-col">
{/* Header */}
<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">
<h2 className="truncate text-base font-semibold">{lead.companyName}</h2>
<Badge variant="outline" className={cn('text-[10px]', meta.cls)}>{meta.label}</Badge>
</div>
<p className="mt-0.5 flex items-center gap-2 text-[11px] text-muted-foreground">
{lead.website && (
<a href={`https://${lead.website}`} target="_blank" rel="noopener noreferrer" className="flex items-center gap-1 hover:text-foreground">
<ExternalLink className="size-3" />{lead.website}
</a>
)}
<span>· {lead.branch ?? '—'}</span>
{lead.city && <span>· {lead.city}</span>}
<span>· Quelle: {lead.source ?? '—'}</span>
</p>
</div>
<div className={cn('flex flex-col items-center rounded-md px-3 py-1.5', scoreColor(lead.score))}>
<span className="text-2xl font-bold tabular-nums leading-none">{lead.score}</span>
<span className="text-[9px] uppercase tracking-wide opacity-80">Score</span>
</div>
</div>
{/* evidence summary */}
<div className="mt-3 flex flex-wrap items-center gap-2">
{passCount > 0 && (
<Badge variant="outline" className="gap-1 text-[10px]">
<CheckCircle2 className="size-3" />{passCount} bestanden
</Badge>
)}
{warnCount > 0 && (
<Badge variant="outline" className="gap-1 text-[10px]">
<AlertTriangle className="size-3" />{warnCount} Warnung
</Badge>
)}
{failCount > 0 && (
<Badge variant="outline" className="gap-1 text-[10px]">
<XCircle className="size-3" />{failCount} kritisch
</Badge>
)}
{lead.scoreLabel && SCORE_LABELS[lead.scoreLabel] && (
<Badge variant="secondary" className="text-[10px]">{SCORE_LABELS[lead.scoreLabel]}</Badge>
)}
</div>
</CardHeader>
<ScrollArea className="flex-1">
<CardContent className="space-y-4 p-4">
{/* Evidence list */}
<div>
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Audit-Evidenz ({lead.evidence.length} Prüfungen)
</p>
<div className="space-y-1.5">
{lead.evidence.map((e) => (
<div key={e.id} className="flex gap-3 rounded-md border border-border p-2.5">
<div className="mt-0.5 shrink-0">
{e.status === 'pass' && <CheckCircle2 className="size-4 text-foreground" />}
{e.status === 'warn' && <AlertTriangle className="size-4 text-muted-foreground" />}
{e.status === 'fail' && <XCircle className="size-4 text-foreground/70" />}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-medium">{e.check}</span>
<code className="rounded bg-muted px-1 py-0.5 text-[10px] font-mono text-muted-foreground">{e.id}</code>
</div>
<p className="mt-0.5 text-[11px] leading-relaxed text-muted-foreground">{e.detail}</p>
</div>
</div>
))}
</div>
</div>
{/* AI explanation */}
<div>
<div className="mb-2 flex items-center justify-between">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
LLM-Erklärung
</p>
<Button size="sm" variant="outline" className="h-7 gap-1.5 text-xs" onClick={generate} disabled={generating}>
{generating ? <Loader2 className="size-3 animate-spin" /> : <Sparkles className="size-3" />}
{lead.explanation ? 'Neu generieren' : 'Erzeugen'}
</Button>
</div>
{lead.explanation ? (
<div className="rounded-md border border-border bg-muted/30 p-3 text-xs leading-relaxed">
{lead.explanation}
</div>
) : (
<div className="rounded-md border border-dashed border-border p-3 text-xs text-muted-foreground">
Noch keine Erklärung. Klicke „Erzeugen", um eine evidenzbasierte Begründung + Winkel vom LLM zu generieren. Jede Behauptung verweist auf eine gespeicherte Evidence-ID.
</div>
)}
{lead.suggestedAngle && (
<div className="mt-2 rounded-md border border-border bg-background p-3">
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">Vorgeschlagener Winkel</p>
<p className="mt-1 text-xs">{lead.suggestedAngle}</p>
</div>
)}
</div>
{lead.reviewReason && (
<div className="rounded-md border border-border bg-muted/20 p-3">
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">Review-Begründung</p>
<p className="mt-1 text-xs">{lead.reviewReason}</p>
</div>
)}
</CardContent>
</ScrollArea>
{/* Actions footer */}
{lead.status === 'pending' && (
<div className="flex items-center gap-2 border-t border-border p-3">
<Button
size="sm"
className="flex-1 gap-1.5"
onClick={() => onReview(lead.id, 'approved')}
>
<Check className="size-4" /> Genehmigen
</Button>
<Button
size="sm"
variant="outline"
className="gap-1.5"
onClick={() => onReview(lead.id, 'rejected', 'Nicht zur Zielgruppe')}
>
<X className="size-4" /> Ablehnen
</Button>
<Button
size="sm"
variant="ghost"
className="gap-1.5"
onClick={() => onReview(lead.id, 'snoozed', 'Später erneut prüfen')}
>
<Clock className="size-4" /> Snooze
</Button>
</div>
)}
{lead.status !== 'pending' && (
<div className="border-t border-border p-3 text-center text-[11px] text-muted-foreground">
Reviewed {formatRelative(lead.reviewedAt ?? lead.createdAt)} · Status: {meta.label}
</div>
)}
</Card>
)
}
+306
View File
@@ -0,0 +1,306 @@
'use client'
import { useState } from 'react'
import {
Plus,
KeyRound,
ShieldCheck,
AlertTriangle,
CalendarClock,
RefreshCw,
CheckCircle2,
XCircle,
PauseCircle,
} 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, formatDate, daysUntil } from '@/lib/format'
import { cn } from '@/lib/utils'
interface License {
id: string
key: string
product: string
plan: string
status: string
maxActivations: number
validUntil: string | null
createdAt: string
company: { id: string; name: string }
activations: { id: string; domain: string; environment: string; version: string | null; lastCheck: string }[]
}
interface Asset {
id: string
name: string
type: string
provider: string | null
costCentsPerMonth: number | null
renewalDate: string
rebill: boolean
markupPct: number
status: string
}
interface LicensesData {
licenses: License[]
assets: Asset[]
stats: {
activeLicenses: number
suspended: number
expired: number
totalActivations: number
expiringSoon: number
expiredAssets: number
monthlyRebill: number
}
}
const LIC_STATUS: Record<string, { label: string; icon: typeof CheckCircle2 }> = {
active: { label: 'Aktiv', icon: CheckCircle2 },
suspended: { label: 'Suspendiert', icon: PauseCircle },
expired: { label: 'Abgelaufen', icon: XCircle },
revoked: { label: 'Widerrufen', icon: XCircle },
}
const ASSET_TYPE_LABELS: Record<string, string> = {
domain: 'Domain',
hosting: 'Hosting',
ssl: 'SSL',
'plugin-seat': 'Plugin-Seat',
saas: 'SaaS',
}
export function LicensesView() {
const { data, loading } = useFetch<LicensesData>('/api/aos/licenses')
if (loading || !data) {
return (
<div className="space-y-6 p-4 md:p-6">
<Skeleton className="h-9 w-32" />
<Skeleton className="h-48" />
</div>
)
}
const { licenses, assets, stats } = data
return (
<div className="space-y-6 p-4 md:p-6">
<PageHeader
title="Lizenzverwaltung"
description="Ausgestellte Produktlizenzen mit Ed25519-signierten Tokens und Erneuerungsregister für verwaltete Assets."
actions={
<Button size="sm">
<Plus className="size-4" />
Lizenz ausstellen
</Button>
}
/>
<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">Aktive Lizenzen</p>
<KeyRound className="size-3.5 text-muted-foreground" />
</div>
<p className="mt-1 text-2xl font-semibold tabular-nums">{stats.activeLicenses}</p>
</Card>
<Card className="p-4">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">Aktivierungen</p>
<ShieldCheck className="size-3.5 text-muted-foreground" />
</div>
<p className="mt-1 text-2xl font-semibold tabular-nums">{stats.totalActivations}</p>
</Card>
<Card className="p-4">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">Erneuerungen fällig</p>
<AlertTriangle className="size-3.5 text-muted-foreground" />
</div>
<p className="mt-1 text-2xl font-semibold tabular-nums">{stats.expiringSoon + stats.expiredAssets}</p>
<p className="text-[10px] text-muted-foreground">{stats.expiredAssets} abgelaufen</p>
</Card>
<Card className="p-4">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">Monatl. Weiterbelastung</p>
<CalendarClock className="size-3.5 text-muted-foreground" />
</div>
<p className="mt-1 text-2xl font-semibold tabular-nums">{formatEuro(stats.monthlyRebill)}</p>
</Card>
</div>
<Tabs defaultValue="licenses">
<TabsList>
<TabsTrigger value="licenses">Ausgestellte Lizenzen</TabsTrigger>
<TabsTrigger value="assets">Erneuerungsregister</TabsTrigger>
</TabsList>
<TabsContent value="licenses" className="mt-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Produktlizenzen ({licenses.length})</CardTitle>
</CardHeader>
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="border-y border-border bg-muted/40 text-left text-[11px] uppercase tracking-wide text-muted-foreground">
<tr>
<th className="px-4 py-2 font-medium">Lizenzschlüssel</th>
<th className="px-4 py-2 font-medium">Produkt / Plan</th>
<th className="px-4 py-2 font-medium">Kunde</th>
<th className="px-4 py-2 font-medium">Aktivierungen</th>
<th className="px-4 py-2 font-medium">Status</th>
<th className="px-4 py-2 font-medium">Gültig bis</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{licenses.map((l) => {
const st = LIC_STATUS[l.status] ?? LIC_STATUS.active
const Icon = st.icon
const valid = l.validUntil ? daysUntil(l.validUntil) : null
return (
<tr key={l.id} className="transition hover:bg-muted/40">
<td className="px-4 py-2.5">
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[11px]">{l.key}</code>
</td>
<td className="px-4 py-2.5">
<p className="text-xs font-medium">{l.product}</p>
<Badge variant="outline" className="mt-0.5 text-[10px] capitalize">{l.plan}</Badge>
</td>
<td className="px-4 py-2.5 text-xs">{l.company.name}</td>
<td className="px-4 py-2.5">
<span className="text-xs tabular-nums">
{l.activations.length} / {l.maxActivations}
</span>
{l.activations[0] && (
<p className="text-[10px] text-muted-foreground">{l.activations[0].domain}</p>
)}
</td>
<td className="px-4 py-2.5">
<Badge variant="outline" className={cn('gap-1 text-[10px]', l.status === 'active' && 'border-foreground/40')}>
<Icon className="size-3" />{st.label}
</Badge>
</td>
<td className="px-4 py-2.5 text-xs">
{l.validUntil ? (
<span className={cn(valid !== null && valid < 0 && 'text-foreground')}>
{formatDate(l.validUntil)}
{valid !== null && (
<span className="ml-1 text-[10px] text-muted-foreground">
{valid < 0 ? `(${Math.abs(valid)} d vorbei)` : `(${valid} d)`}
</span>
)}
</span>
) : '—'}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</CardContent>
</Card>
{/* Signing info card */}
<Card className="mt-4">
<CardContent className="p-4">
<div className="flex items-start gap-3">
<div className="flex size-9 items-center justify-center rounded-md border border-border">
<ShieldCheck className="size-4 text-muted-foreground" />
</div>
<div className="flex-1">
<p className="text-sm font-medium">Ed25519-signierte Lizenz-Tokens</p>
<p className="mt-1 text-xs text-muted-foreground">
Jede Lizenz hat einen menschenlesbaren Schlüssel plus einen kryptografisch signierten Token. Kunden können offline verifizieren.
Der private Schlüssel ist verschlüsselt (AES-256-GCM); Rotation wird unterstützt.
</p>
<div className="mt-2 flex flex-wrap gap-2">
<Badge variant="outline" className="text-[10px]">Public API: /api/v1/validate</Badge>
<Badge variant="outline" className="text-[10px]">WordPress-Updater kompatibel</Badge>
<Badge variant="outline" className="text-[10px]">Offline-Grace: 14 d</Badge>
</div>
</div>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="assets" className="mt-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Asset- & Erneuerungsregister ({assets.length})</CardTitle>
</CardHeader>
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="border-y border-border bg-muted/40 text-left text-[11px] uppercase tracking-wide text-muted-foreground">
<tr>
<th className="px-4 py-2 font-medium">Asset</th>
<th className="px-4 py-2 font-medium">Typ</th>
<th className="px-4 py-2 font-medium">Anbieter</th>
<th className="px-4 py-2 font-medium">Kosten/Monat</th>
<th className="px-4 py-2 font-medium">Erneuerung</th>
<th className="px-4 py-2 font-medium">Weiterbelasten</th>
<th className="px-4 py-2 font-medium">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{assets.map((a) => {
const days = daysUntil(a.renewalDate)
return (
<tr key={a.id} className="transition hover:bg-muted/40">
<td className="px-4 py-2.5 text-xs font-medium">{a.name}</td>
<td className="px-4 py-2.5">
<Badge variant="secondary" className="text-[10px]">{ASSET_TYPE_LABELS[a.type] ?? a.type}</Badge>
</td>
<td className="px-4 py-2.5 text-xs text-muted-foreground">{a.provider ?? '—'}</td>
<td className="px-4 py-2.5 text-xs tabular-nums">
{a.costCentsPerMonth ? formatEuro(a.costCentsPerMonth) : '—'}
</td>
<td className="px-4 py-2.5 text-xs">
<span className={cn(days < 0 && 'text-foreground')}>
{formatDate(a.renewalDate)}
</span>
<span className="ml-1 text-[10px] text-muted-foreground">
{days < 0 ? `(${Math.abs(days)} d vorbei)` : `(${days} d)`}
</span>
</td>
<td className="px-4 py-2.5">
{a.rebill ? (
<Badge variant="outline" className="text-[10px] gap-1">
<RefreshCw className="size-2.5" />+{a.markupPct}%
</Badge>
) : (
<span className="text-[10px] text-muted-foreground">—</span>
)}
</td>
<td className="px-4 py-2.5">
<Badge
variant="outline"
className={cn(
'text-[10px]',
a.status === 'expired' && 'border-foreground/40',
a.status === 'expiring' && 'border-foreground/30'
)}
>
{a.status === 'expired' ? 'Abgelaufen' : a.status === 'expiring' ? 'Läuft ab' : 'Aktiv'}
</Badge>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
)
}
+350
View File
@@ -0,0 +1,350 @@
'use client'
import { useState } from 'react'
import {
Plus,
FolderKanban,
Clock,
Calendar,
CheckCircle2,
Circle,
PlayCircle,
Eye,
Timer,
Play,
Pause,
} 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 { Progress } from '@/components/ui/progress'
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { PageHeader } from '../primitives'
import { formatEuro, formatDate, daysUntil, formatMinutes } from '@/lib/format'
import { cn } from '@/lib/utils'
interface Project {
id: string
name: string
type: string
status: string
budgetType: string
budgetHours: number | null
spentHours: number
fixedPriceCents: number | null
startDate: string | null
dueDate: string | null
progress: number
company: { id: string; name: string }
tasks: Task[]
timeEntries: TimeEntry[]
}
interface Task { id: string; title: string; status: string; assignee: string | null; estimateMin: number | null; priority: string }
interface TimeEntry { id: string; description: string; minutes: number; billable: boolean; rateCentsPerHour: number | null; date: string }
interface ProjectsData {
projects: Project[]
minutesThisWeek: number
}
const STATUS_LABELS: Record<string, string> = {
planning: 'Planung',
active: 'Aktiv',
review: 'Review',
done: 'Abgeschlossen',
'on-hold': 'Pausiert',
cancelled: 'Abgebrochen',
}
const TYPE_LABELS: Record<string, string> = {
website: 'Website',
shop: 'Shop',
app: 'App',
retainer: 'Retainer',
}
const TASK_ICON = {
todo: Circle,
'in-progress': PlayCircle,
review: Eye,
done: CheckCircle2,
}
export function ProjectsView() {
const { data, loading } = useFetch<ProjectsData>('/api/aos/projects')
const [selectedId, setSelectedId] = useState<string | null>(null)
if (loading || !data) {
return (
<div className="space-y-6 p-4 md:p-6">
<Skeleton className="h-9 w-32" />
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
{Array.from({ length: 3 }).map((_, i) => <Skeleton key={i} className="h-48" />)}
</div>
</div>
)
}
const { projects, minutesThisWeek } = data
const selected = projects.find((p) => p.id === selectedId) ?? projects[0] ?? null
const totalHours = projects.reduce((s, p) => s + p.spentHours, 0)
const totalBudget = projects.filter((p) => p.fixedPriceCents).reduce((s, p) => s + (p.fixedPriceCents ?? 0), 0)
return (
<div className="space-y-6 p-4 md:p-6">
<PageHeader
title="Projekte"
description="Projekte, Aufgaben, Zeitverfolgung, Budgets und Change Requests. Mit Client-Portal-Freigaben."
actions={
<Button size="sm">
<Plus className="size-4" />
Projekt
</Button>
}
/>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<Card className="p-4">
<p className="text-xs text-muted-foreground">Aktive Projekte</p>
<p className="mt-1 text-2xl font-semibold tabular-nums">{projects.filter((p) => p.status === 'active').length}</p>
</Card>
<Card className="p-4">
<p className="text-xs text-muted-foreground">Stunden gesamt</p>
<p className="mt-1 text-2xl font-semibold tabular-nums">{totalHours}</p>
</Card>
<Card className="p-4">
<p className="text-xs text-muted-foreground">Diese Woche</p>
<p className="mt-1 text-2xl font-semibold tabular-nums">{formatMinutes(minutesThisWeek)}</p>
</Card>
<Card className="p-4">
<p className="text-xs text-muted-foreground">Fixed-Price-Volumen</p>
<p className="mt-1 text-2xl font-semibold tabular-nums">{formatEuro(totalBudget)}</p>
</Card>
</div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-5">
{/* Project list */}
<div className="space-y-3 lg:col-span-2">
{projects.map((p) => {
const due = p.dueDate ? daysUntil(p.dueDate) : null
const overBudget = p.budgetHours && p.spentHours > p.budgetHours
return (
<Card
key={p.id}
className={cn('cursor-pointer p-4 transition hover:border-foreground/40', selected?.id === p.id && 'border-foreground/40 ring-1 ring-foreground/20')}
onClick={() => setSelectedId(p.id)}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate text-sm font-semibold">{p.name}</p>
<p className="mt-0.5 truncate text-[11px] text-muted-foreground">{p.company.name}</p>
</div>
<Badge variant="outline" className="shrink-0 text-[10px]">{STATUS_LABELS[p.status] ?? p.status}</Badge>
</div>
<div className="mt-3 flex flex-wrap items-center gap-1.5">
<Badge variant="secondary" className="text-[10px]">{TYPE_LABELS[p.type] ?? p.type}</Badge>
<Badge variant="outline" className="text-[10px] capitalize">{p.budgetType}</Badge>
{p.fixedPriceCents && (
<span className="text-[11px] tabular-nums text-muted-foreground">{formatEuro(p.fixedPriceCents)}</span>
)}
</div>
<div className="mt-3">
<div className="mb-1 flex items-center justify-between text-[11px]">
<span className="text-muted-foreground">Fortschritt</span>
<span className="font-medium tabular-nums">{p.progress}%</span>
</div>
<Progress value={p.progress} className="h-1.5" />
</div>
<div className="mt-3 flex items-center justify-between text-[11px] text-muted-foreground">
<span className="flex items-center gap-1">
<Clock className="size-3" />
{p.spentHours}{p.budgetHours ? ` / ${p.budgetHours} h` : ' h'}
{overBudget && <span className="text-foreground"> · über Budget</span>}
</span>
{due !== null && (
<span className={cn('flex items-center gap-1', due < 0 && 'text-foreground')}>
<Calendar className="size-3" />
{due < 0 ? `${Math.abs(due)} d überfällig` : `in ${due} d`}
</span>
)}
</div>
</Card>
)
})}
</div>
{/* Detail */}
<div className="lg:col-span-3">
{selected ? <ProjectDetail project={selected} /> : (
<Card className="flex h-full items-center justify-center">
<p className="text-sm text-muted-foreground">Wähle ein Projekt.</p>
</Card>
)}
</div>
</div>
</div>
)
}
function ProjectDetail({ project }: { project: Project }) {
const [running, setRunning] = useState(false)
const todo = project.tasks.filter((t) => t.status === 'todo')
const inProgress = project.tasks.filter((t) => t.status === 'in-progress')
const review = project.tasks.filter((t) => t.status === 'review')
const done = project.tasks.filter((t) => t.status === 'done')
return (
<Card>
<CardHeader className="border-b border-border pb-3">
<div className="flex items-start justify-between gap-2">
<div>
<CardTitle className="text-base">{project.name}</CardTitle>
<p className="mt-0.5 text-[11px] text-muted-foreground">{project.company.name}</p>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-[10px]">{STATUS_LABELS[project.status]}</Badge>
</div>
</div>
</CardHeader>
<CardContent className="p-4">
<Tabs defaultValue="tasks">
<TabsList className="w-full justify-start">
<TabsTrigger value="tasks">Aufgaben</TabsTrigger>
<TabsTrigger value="time">Zeit</TabsTrigger>
<TabsTrigger value="budget">Budget</TabsTrigger>
</TabsList>
<TabsContent value="tasks" className="mt-4 space-y-4">
<TaskColumn label="Offen" tasks={todo} />
<TaskColumn label="In Arbeit" tasks={inProgress} />
<TaskColumn label="Review" tasks={review} />
<TaskColumn label="Erledigt" tasks={done} muted />
</TabsContent>
<TabsContent value="time" className="mt-4 space-y-3">
{/* Live timer mock */}
<div className="flex items-center gap-3 rounded-md border border-border bg-muted/20 p-3">
<Button size="sm" variant={running ? 'default' : 'outline'} className="gap-1.5" onClick={() => setRunning((r) => !r)}>
{running ? <Pause className="size-3.5" /> : <Play className="size-3.5" />}
{running ? 'Stop' : 'Timer starten'}
</Button>
<div className="flex items-center gap-1.5 text-sm">
<Timer className="size-4 text-muted-foreground" />
<span className="font-mono tabular-nums">{running ? '00:42:18' : '00:00:00'}</span>
</div>
<span className="ml-auto text-[11px] text-muted-foreground">An: {project.name}</span>
</div>
<div className="space-y-2">
{project.timeEntries.map((te) => (
<div key={te.id} className="flex items-center gap-3 rounded-md border border-border p-2.5">
<Clock className="size-3.5 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-medium">{te.description}</p>
<p className="text-[10px] text-muted-foreground">{formatDate(te.date)}</p>
</div>
<div className="text-right">
<p className="text-xs font-semibold tabular-nums">{formatMinutes(te.minutes)}</p>
{te.billable && te.rateCentsPerHour && (
<p className="text-[10px] text-muted-foreground">{formatEuro(te.rateCentsPerHour)}/h</p>
)}
</div>
</div>
))}
{project.timeEntries.length === 0 && (
<p className="py-6 text-center text-xs text-muted-foreground">Keine Zeiteinträge.</p>
)}
</div>
</TabsContent>
<TabsContent value="budget" className="mt-4 space-y-3">
<div className="grid grid-cols-2 gap-3">
<div className="rounded-md border border-border p-3">
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">Typ</p>
<p className="mt-1 text-sm font-medium capitalize">{project.budgetType.replace('-', ' & ')}</p>
</div>
{project.fixedPriceCents && (
<div className="rounded-md border border-border p-3">
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">Fixed Price</p>
<p className="mt-1 text-sm font-semibold tabular-nums">{formatEuro(project.fixedPriceCents)}</p>
</div>
)}
{project.budgetHours && (
<div className="rounded-md border border-border p-3">
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">Budget-Stunden</p>
<p className="mt-1 text-sm font-semibold tabular-nums">{project.budgetHours} h</p>
</div>
)}
<div className="rounded-md border border-border p-3">
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">Verbraucht</p>
<p className="mt-1 text-sm font-semibold tabular-nums">{project.spentHours} h</p>
</div>
</div>
{project.budgetHours && (
<div>
<div className="mb-1 flex items-center justify-between text-[11px]">
<span className="text-muted-foreground">Budget-Auslastung</span>
<span className="font-medium tabular-nums">
{Math.round((project.spentHours / project.budgetHours) * 100)}%
</span>
</div>
<Progress
value={(project.spentHours / project.budgetHours) * 100}
className="h-2"
/>
{project.spentHours > project.budgetHours && (
<p className="mt-2 text-[11px] text-foreground">⚠ Über Budget — Change Request prüfen.</p>
)}
</div>
)}
<div className="rounded-md border border-dashed border-border p-3 text-[11px] text-muted-foreground">
<p className="font-medium text-foreground">Change Requests</p>
<p className="mt-1">Scope-Änderung → bepreistes Angebot → Freigabe → Aufgaben hinzugefügt. (v1 Demo)</p>
</div>
</TabsContent>
</Tabs>
</CardContent>
</Card>
)
}
function TaskColumn({ label, tasks, muted }: { label: string; tasks: Task[]; muted?: boolean }) {
return (
<div>
<div className="mb-1.5 flex items-center gap-2">
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">{label}</span>
<span className="text-[10px] text-muted-foreground">({tasks.length})</span>
</div>
<div className="space-y-1.5">
{tasks.map((t) => {
const Icon = TASK_ICON[t.status as keyof typeof TASK_ICON] ?? Circle
return (
<div key={t.id} className={cn('flex items-center gap-2.5 rounded-md border border-border p-2.5', muted && 'opacity-70')}>
<Icon className="size-3.5 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<p className="truncate text-xs">{t.title}</p>
<div className="mt-0.5 flex items-center gap-2 text-[10px] text-muted-foreground">
{t.assignee && <span>{t.assignee}</span>}
{t.estimateMin && <span>· ~{formatMinutes(t.estimateMin)}</span>}
</div>
</div>
{t.priority === 'high' && !muted && (
<Badge variant="outline" className="text-[9px]">hoch</Badge>
)}
</div>
)
})}
{tasks.length === 0 && (
<div className="rounded border border-dashed border-border py-3 text-center text-[10px] text-muted-foreground">—</div>
)}
</div>
</div>
)
}
+236
View File
@@ -0,0 +1,236 @@
'use client'
import { useState } from 'react'
import {
Settings2,
ShieldCheck,
Boxes,
Lock,
Loader2,
CheckCircle2,
Circle,
AlertTriangle,
Database,
} 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 { Switch } from '@/components/ui/switch'
import { Skeleton } from '@/components/ui/skeleton'
import { PageHeader } from '../primitives'
import { cn } from '@/lib/utils'
import { toast } from 'sonner'
interface ModuleData {
id: string
version: string
enabled: boolean
installed: boolean
}
interface ModulesResponse {
modules: ModuleData[]
settings: Record<string, string>
}
const MODULE_META: Record<string, { label: string; desc: string; phase: string }> = {
crm: { label: 'CRM', desc: 'Firmen, Personen, Deals, Pipeline, Mail-Sync.', phase: 'Phase 1' },
leads: { label: 'Leads Finder', desc: 'Evidenzbasierte Website-Audits + LLM-Erklärung.', phase: 'Phase 2' },
projects: { label: 'Projekte', desc: 'Aufgaben, Zeit, Budgets, Client-Portal.', phase: 'Phase 3' },
licenses: { label: 'Lizenzverwaltung', desc: 'Ausgestellte Lizenzen + Erneuerungsregister.', phase: 'Phase 4' },
'sales-docs': { label: 'Angebote & Verträge', desc: 'Angebote, Auftragsbestätigung, Vertragstemplates.', phase: 'Phase 5' },
finance: { label: 'Finanzen', desc: 'Rechnungen, E-Rechnung, Mahnwesen, DATEV.', phase: 'Phase 5' },
support: { label: 'Support', desc: 'Ticket-Inbox, SLA-Timer (geplant).', phase: 'Later' },
reporting: { label: 'Reporting', desc: 'Dashboards: Pipeline, Auslastung, MRR (geplant).', phase: 'Later' },
}
const COMPLIANCE_ITEMS = [
{ id: 'gobd', label: 'GoBD: Unveränderlichkeit & Hash-Kette', desc: 'Audit-Trigger, Storno statt Edit, Aufbewahrung 8/6 J.' },
{ id: 'dsgvo', label: 'DSGVO: Konsens- & Verarbeitungsprotokoll', desc: 'Art. 6/7/14 je Kontakt, Export & Löschung.' },
{ id: 'erechnung', label: 'E-Rechnung: ZUGFeRD / XRechnung', desc: 'EN 16931 validiert via @e-invoice-eu/core.' },
{ id: 'uwg', label: 'UWG § 7: Cold-Outreach-Gate', desc: 'Kein Auto-Versand, Suppression-Liste.' },
{ id: 'tdddg', label: 'TDDDG: Cookie-/Tracking-Einwilligung', desc: 'Kein Tracking im Portal/öffentlichen Formularen.' },
{ id: 'bfsg', label: 'BFSG: Barrierefreiheit (WCAG 2.1 AA)', desc: 'Launch-Checklist für Kunden-Projekte.' },
{ id: 'retention', label: 'Aufbewahrungsfristen (Finanz & CRM)', desc: 'Automatische Lösch-Jobs nach Frist.' },
{ id: 'encryption', label: 'Verschlüsselung: Secrets AES-256-GCM', desc: 'ENCRYPTION_KEY separat vom DB-Backup.' },
]
export function SettingsView() {
const { data, loading, refetch } = useFetch<ModulesResponse>('/api/aos/modules')
const [toggling, setToggling] = useState<string | null>(null)
const toggle = async (id: string, enabled: boolean) => {
setToggling(id)
const t = toast.loading(enabled ? 'Modul wird aktiviert…' : 'Modul wird deaktiviert…')
try {
const r = await fetch('/api/aos/modules', {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ id, enabled }),
})
if (!r.ok) throw new Error('toggle failed')
toast.success(enabled ? 'Modul aktiviert' : 'Modul deaktiviert', { id: t })
refetch()
} catch {
toast.error('Umschalten fehlgeschlagen', { id: t })
} finally {
setToggling(null)
}
}
if (loading || !data) {
return (
<div className="space-y-6 p-4 md:p-6">
<Skeleton className="h-9 w-40" />
<Skeleton className="h-64" />
</div>
)
}
const enabledCount = data.modules.filter((m) => m.enabled).length
const installedCount = data.modules.filter((m) => m.installed).length
return (
<div className="space-y-6 p-4 md:p-6">
<PageHeader
title="Erweiterungen & System"
description="Modular: Jede Domäne ist ein Modul. In v1 wird „Aktivieren“ via Rebuild ausgeführt – kein Hot-Plug."
/>
<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">Installierte Module</p>
<Boxes className="size-3.5 text-muted-foreground" />
</div>
<p className="mt-1 text-2xl font-semibold tabular-nums">{installedCount}</p>
</Card>
<Card className="p-4">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">Aktiviert</p>
<CheckCircle2 className="size-3.5 text-muted-foreground" />
</div>
<p className="mt-1 text-2xl font-semibold tabular-nums">{enabledCount}</p>
</Card>
<Card className="p-4">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">Compliance-Bereiche</p>
<ShieldCheck className="size-3.5 text-muted-foreground" />
</div>
<p className="mt-1 text-2xl font-semibold tabular-nums">{COMPLIANCE_ITEMS.length}</p>
</Card>
<Card className="p-4">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">AOS-Version</p>
<Database className="size-3.5 text-muted-foreground" />
</div>
<p className="mt-1 text-2xl font-semibold tabular-nums">1.0.0-dev</p>
</Card>
</div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-5">
{/* Modules */}
<Card className="lg:col-span-3">
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm">
<Boxes className="size-3.5 text-muted-foreground" />
Module
</CardTitle>
</CardHeader>
<CardContent className="divide-y divide-border p-0">
{data.modules.map((m) => {
const meta = MODULE_META[m.id] ?? { label: m.id, desc: '', phase: '' }
const busy = toggling === m.id
return (
<div key={m.id} className="flex items-center gap-3 px-4 py-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="text-sm font-medium">{meta.label}</p>
<Badge variant="outline" className="text-[10px]">{meta.phase}</Badge>
<code className="text-[10px] text-muted-foreground">v{m.version}</code>
</div>
{meta.desc && <p className="mt-0.5 text-[11px] text-muted-foreground">{meta.desc}</p>}
</div>
<div className="flex items-center gap-2">
{busy && <Loader2 className="size-3.5 animate-spin text-muted-foreground" />}
<Switch
checked={m.enabled}
onCheckedChange={(v) => toggle(m.id, v)}
disabled={busy || m.id === 'crm'}
aria-label={`${meta.label} ${m.enabled ? 'deaktivieren' : 'aktivieren'}`}
/>
</div>
</div>
)
})}
</CardContent>
</Card>
{/* Compliance checklist */}
<Card className="lg:col-span-2">
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm">
<ShieldCheck className="size-3.5 text-muted-foreground" />
Compliance-Checkliste
</CardTitle>
</CardHeader>
<CardContent className="space-y-2.5 p-4">
{COMPLIANCE_ITEMS.map((item) => (
<div key={item.id} className="flex items-start gap-2.5">
<CheckCircle2 className="mt-0.5 size-4 shrink-0 text-foreground" />
<div className="min-w-0">
<p className="text-xs font-medium">{item.label}</p>
<p className="text-[11px] text-muted-foreground">{item.desc}</p>
</div>
</div>
))}
<div className="mt-2 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">
<AlertTriangle className="size-3" />
CI blockiert Merges ohne Audit- & Aufbewahrungs-Metadaten.
</p>
</div>
</CardContent>
</Card>
</div>
{/* Agency settings preview */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm">
<Settings2 className="size-3.5 text-muted-foreground" />
Agentur-Stammdaten
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
<SettingRow label="Firma" value={data.settings['company.name']} />
<SettingRow label="Rechtsform" value={data.settings['company.legalForm']} />
<SettingRow label="USt-IdNr." value={data.settings['company.vatId']} mono />
<SettingRow label="Registergericht" value={data.settings['company.registerCourt']} />
<SettingRow label="HRB-Nummer" value={data.settings['company.registerNumber']} mono />
<SettingRow label="Sitz" value={data.settings['company.city']} />
<SettingRow label="Rechnungspräfix" value={data.settings['invoice.numberPrefix']} mono />
<SettingRow label="Steuersatz (Standard)" value={`${(Number(data.settings['tax.standardBp']) / 100).toFixed(0)} %`} />
<SettingRow label="Lead-Radius" value={`${data.settings['leadFinder.radiusKm']} km`} />
</div>
<div className="mt-4 flex items-center gap-2 rounded-md border border-border bg-muted/30 p-3 text-[11px]">
<Lock className="size-3.5 text-muted-foreground" />
<span className="text-muted-foreground">
Secrets (SMTP, API-Keys, Signier-Schlüssel) sind AES-256-GCM verschlüsselt. <code className="font-mono">ENCRYPTION_KEY</code> wird separat vom DB-Backup verwahrt.
</span>
</div>
</CardContent>
</Card>
</div>
)
}
function SettingRow({ label, value, mono }: { label: string; value?: string; mono?: boolean }) {
return (
<div className="rounded-md border border-border p-3">
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">{label}</p>
<p className={cn('mt-1 text-sm font-medium', mono && 'font-mono')}>{value ?? '—'}</p>
</div>
)
}