b6167bcb-7363-4695-9481-addd81de4367
This commit is contained in:
1 parent
fdc3b82d10
commit
d226dabba3
136 files changed
+13904
-2
No files matched your search
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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')} />
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user