Files
AOS/src/components/aos/views/reporting.tsx
T
Z User fa8dd46571 refactor: professional code quality audit
- Fix all TypeScript compilation errors (0 errors in src/)
- Add try/catch error handling to ALL API routes (38 handlers)
- Fix empty catch blocks (api-auth.ts now logs warnings)
- Add aria-labels to all icon-only buttons (WCAG 2.1 AA)
- Fix Prisma type mismatches (OR filter, Person.companyId, Estimate.projectId, Ticket.createdAt)
- Fix webhook PATCH route (moved to [id]/route.ts)
- Fix mobile-bottom-nav type safety
- Fix KB route where-clause type
- Fix test-mail banner null vs undefined
- Fix task comment fetch: add cancellation + error toast
- ESLint: 0 errors, 0 warnings
- 175 files, 24.741 lines of code
2026-10-27 12:23:32 +00:00

398 lines
19 KiB
TypeScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import {
BarChart3,
TrendingUp,
Users,
Repeat,
Activity,
CalendarClock,
ArrowRight,
} from 'lucide-react'
import { useFetch } from '@/hooks/use-fetch'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { PageHeader } from '../primitives'
import { formatEuro, formatEuroCompact, formatDate } from '@/lib/format'
import { useAos } from '@/store/aos'
import {
Bar,
BarChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
Cell,
Line,
LineChart,
CartesianGrid,
Area,
AreaChart,
} from 'recharts'
interface ReportingData {
pipeline: {
stages: { stage: string; label: string; count: number; valueCents: number }[]
pipelineValue: number
wonValue: number
lostValue: number
conversionRate: number
avgDealSizeCents: number
}
utilization: {
byProject: { label: string; totalHours: number; billableHours: number; utilizationPct: number }[]
weekly: { label: string; hours: number }[]
}
recurring: {
mrr: number
arr: number
recurringContractsMrr: number
rebillMrr: number
recurringExpensesMrr: number
renewalsDue: { name: string; type: string; renewalDate: string; daysUntil: number }[]
activeLicenses: number
}
revenue: { label: string; cents: number }[]
activity: { group: string; count: number }[]
estimateWinRate: number
}
export function ReportingView() {
const { data, loading } = useFetch<ReportingData>('/api/aos/reporting')
const setView = useAos((s) => s.setView)
if (loading || !data) {
return (
<div className="space-y-6 p-4 md:p-6">
<Skeleton className="h-9 w-32" />
<Skeleton className="h-64" />
</div>
)
}
const { pipeline, utilization, recurring, revenue, activity } = data
const maxStageValue = Math.max(...pipeline.stages.map((s) => s.valueCents), 1)
const maxHours = Math.max(...utilization.weekly.map((w) => w.hours), 1)
const maxRev = Math.max(...revenue.map((r) => r.cents), 1)
return (
<div className="space-y-6 p-4 md:p-6">
<PageHeader
title="Reporting"
description="Cross-module Dashboards: Pipeline, Auslastung, wiederkehrende Umsätze (MRR/ARR), Aktivität. Datengetrieben, keine manuelle Zusammenstellung."
/>
{/* KPI summary */}
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<Card className="p-4">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">Offene Pipeline</p>
<TrendingUp className="size-3.5 text-muted-foreground" />
</div>
<p className="mt-1 text-2xl font-semibold tabular-nums">{formatEuroCompact(pipeline.pipelineValue)}</p>
<p className="text-[10px] text-muted-foreground">Conversion: {pipeline.conversionRate}%</p>
</Card>
<Card className="p-4">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">MRR</p>
<Repeat className="size-3.5 text-muted-foreground" />
</div>
<p className="mt-1 text-2xl font-semibold tabular-nums">{formatEuroCompact(recurring.mrr)}</p>
<p className="text-[10px] text-muted-foreground">ARR: {formatEuroCompact(recurring.arr)}</p>
</Card>
<Card className="p-4">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">Ø Deal-Größe</p>
<BarChart3 className="size-3.5 text-muted-foreground" />
</div>
<p className="mt-1 text-2xl font-semibold tabular-nums">{formatEuroCompact(pipeline.avgDealSizeCents)}</p>
</Card>
<Card className="p-4">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">Angebot-Win-Rate</p>
<ArrowRight className="size-3.5 text-muted-foreground" />
</div>
<p className="mt-1 text-2xl font-semibold tabular-nums">{data.estimateWinRate}<span className="text-sm text-muted-foreground">%</span></p>
</Card>
</div>
<Tabs defaultValue="pipeline">
<TabsList>
<TabsTrigger value="pipeline">Pipeline</TabsTrigger>
<TabsTrigger value="utilization">Auslastung</TabsTrigger>
<TabsTrigger value="recurring">MRR & Erneuerungen</TabsTrigger>
<TabsTrigger value="activity">Aktivität</TabsTrigger>
</TabsList>
{/* Pipeline tab */}
<TabsContent value="pipeline" className="mt-4 space-y-4">
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
<Card className="lg:col-span-2">
<CardHeader className="pb-2">
<CardTitle className="text-sm">Pipeline nach Phase</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2.5">
{pipeline.stages.filter((s) => s.stage !== 'lost').map((s) => (
<div key={s.stage}>
<div className="mb-1 flex items-center justify-between text-xs">
<span className="font-medium">{s.label}</span>
<span className="text-muted-foreground">{s.count} Deals · {formatEuroCompact(s.valueCents)}</span>
</div>
<div className="h-2.5 w-full overflow-hidden rounded bg-muted">
<div className="h-full rounded bg-foreground transition-all duration-500" style={{ width: `${(s.valueCents / maxStageValue) * 100}%` }} />
</div>
</div>
))}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Umsatz (6 Monate, bezahlt)</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={200}>
<AreaChart data={revenue} margin={{ top: 4, right: 4, bottom: 0, left: -20 }}>
<defs>
<linearGradient id="revGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="var(--foreground)" stopOpacity={0.3} />
<stop offset="100%" stopColor="var(--foreground)" stopOpacity={0} />
</linearGradient>
</defs>
<XAxis dataKey="label" tick={{ fontSize: 11, fill: 'var(--muted-foreground)' }} axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 11, fill: 'var(--muted-foreground)' }} axisLine={false} tickLine={false} tickFormatter={(v) => formatEuroCompact(v).replace(' €', '')} width={50} />
<Tooltip
contentStyle={{ background: 'var(--popover)', border: '1px solid var(--border)', borderRadius: 'var(--radius)', fontSize: 12 }}
formatter={(v: number) => [formatEuro(v), 'Umsatz']}
/>
<Area type="monotone" dataKey="cents" stroke="var(--foreground)" strokeWidth={2} fill="url(#revGrad)" />
</AreaChart>
</ResponsiveContainer>
</CardContent>
</Card>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<Card className="p-4">
<p className="text-xs text-muted-foreground">Gewonnen (YTD)</p>
<p className="mt-1 text-xl font-semibold tabular-nums">{formatEuro(pipeline.wonValue)}</p>
</Card>
<Card className="p-4">
<p className="text-xs text-muted-foreground">Verloren</p>
<p className="mt-1 text-xl font-semibold tabular-nums text-muted-foreground">{formatEuro(pipeline.lostValue)}</p>
</Card>
<Card className="p-4">
<p className="text-xs text-muted-foreground">Conversion-Rate</p>
<p className="mt-1 text-xl font-semibold tabular-nums">{pipeline.conversionRate}%</p>
</Card>
</div>
</TabsContent>
{/* Utilization tab */}
<TabsContent value="utilization" className="mt-4 space-y-4">
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm">
<Users className="size-3.5 text-muted-foreground" />
Auslastung nach Projekt
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2.5">
{utilization.byProject.map((u) => (
<div key={u.label}>
<div className="mb-1 flex items-center justify-between text-xs">
<span className="truncate font-medium">{u.label}</span>
<span className="tabular-nums text-muted-foreground">{u.billableHours}/{u.totalHours} h · {u.utilizationPct}%</span>
</div>
<div className="h-2 w-full overflow-hidden rounded bg-muted">
<div className="h-full rounded bg-foreground" style={{ width: `${u.utilizationPct}%` }} />
</div>
</div>
))}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Wöchentliche Stunden (12 Wochen)</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={200}>
<BarChart data={utilization.weekly} margin={{ top: 4, right: 4, bottom: 0, left: -20 }}>
<XAxis dataKey="label" tick={{ fontSize: 10, fill: 'var(--muted-foreground)' }} axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 11, fill: 'var(--muted-foreground)' }} axisLine={false} tickLine={false} width={40} />
<Tooltip
cursor={{ fill: 'var(--muted)', opacity: 0.4 }}
contentStyle={{ background: 'var(--popover)', border: '1px solid var(--border)', borderRadius: 'var(--radius)', fontSize: 12 }}
formatter={(v: number) => [`${v} h`, 'Stunden']}
/>
<Bar dataKey="hours" radius={[3, 3, 0, 0]}>
{utilization.weekly.map((_, i) => (
<Cell key={i} fill="var(--foreground)" fillOpacity={0.35 + (i / utilization.weekly.length) * 0.65} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
</div>
</TabsContent>
{/* MRR & Renewals tab */}
<TabsContent value="recurring" className="mt-4 space-y-4">
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">MRR-Zusammensetzung</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
<MrrRow label="Wartungs-/Dienstverträge" cents={recurring.recurringContractsMrr} total={recurring.mrr} />
<MrrRow label="Weiterbelastete Assets (+Markup)" cents={recurring.rebillMrr} total={recurring.mrr} />
<MrrRow label="Wiederkehrende Ausgaben" cents={recurring.recurringExpensesMrr} total={recurring.mrr} negative />
</div>
<div className="mt-3 border-t border-border pt-3">
<div className="flex items-center justify-between">
<span className="text-sm font-semibold">MRR gesamt</span>
<span className="text-lg font-semibold tabular-nums">{formatEuro(recurring.mrr)}</span>
</div>
<div className="mt-1 flex items-center justify-between text-xs text-muted-foreground">
<span>ARR (×12)</span>
<span className="tabular-nums">{formatEuro(recurring.arr)}</span>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm">
<CalendarClock className="size-3.5 text-muted-foreground" />
Erneuerungen (60 Tage)
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2 max-h-56 overflow-y-auto [scrollbar-width:thin]">
{recurring.renewalsDue.length === 0 && (
<p className="py-6 text-center text-xs text-muted-foreground">Keine anstehenden Erneuerungen.</p>
)}
{recurring.renewalsDue.map((r, i) => (
<div key={i} className="flex items-center justify-between rounded-md border border-border p-2 text-xs">
<div className="min-w-0">
<p className="truncate font-medium">{r.name}</p>
<p className="text-[10px] text-muted-foreground">{r.type} · {formatDate(r.renewalDate)}</p>
</div>
<Badge variant="outline" className={r.daysUntil < 7 ? 'border-foreground/50' : ''}>
{r.daysUntil < 0 ? `${Math.abs(r.daysUntil)} d drüber` : `${r.daysUntil} d`}
</Badge>
</div>
))}
</div>
</CardContent>
</Card>
</div>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<Card className="p-4">
<p className="text-xs text-muted-foreground">Aktive Lizenzen</p>
<p className="mt-1 text-xl font-semibold tabular-nums">{recurring.activeLicenses}</p>
</Card>
<Card className="p-4">
<p className="text-xs text-muted-foreground">Erneuerungen ≤ 60 d</p>
<p className="mt-1 text-xl font-semibold tabular-nums">{recurring.renewalsDue.length}</p>
</Card>
<Card className="p-4">
<p className="text-xs text-muted-foreground">MRR</p>
<p className="mt-1 text-xl font-semibold tabular-nums">{formatEuroCompact(recurring.mrr)}</p>
</Card>
<Card className="p-4">
<p className="text-xs text-muted-foreground">ARR</p>
<p className="mt-1 text-xl font-semibold tabular-nums">{formatEuroCompact(recurring.arr)}</p>
</Card>
</div>
</TabsContent>
{/* Activity tab */}
<TabsContent value="activity" className="mt-4 space-y-4">
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm">
<Activity className="size-3.5 text-muted-foreground" />
Aktivität nach Aktionsgruppe (30 Tage)
</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={220}>
<BarChart data={activity} layout="vertical" margin={{ top: 4, right: 16, bottom: 0, left: 20 }}>
<XAxis type="number" tick={{ fontSize: 11, fill: 'var(--muted-foreground)' }} axisLine={false} tickLine={false} />
<YAxis type="category" dataKey="group" tick={{ fontSize: 10, fill: 'var(--muted-foreground)' }} axisLine={false} tickLine={false} width={100} />
<Tooltip
cursor={{ fill: 'var(--muted)', opacity: 0.4 }}
contentStyle={{ background: 'var(--popover)', border: '1px solid var(--border)', borderRadius: 'var(--radius)', fontSize: 12 }}
formatter={(v: number) => [`${v} Ereignisse`, 'Anzahl']}
/>
<Bar dataKey="count" radius={[0, 3, 3, 0]}>
{activity.map((_, i) => (
<Cell key={i} fill="var(--foreground)" fillOpacity={0.4 + (i / Math.max(activity.length, 1)) * 0.6} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Umsatz- & Aktivitätstrend</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={220}>
<LineChart data={revenue} margin={{ top: 4, right: 4, bottom: 0, left: -20 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" vertical={false} />
<XAxis dataKey="label" tick={{ fontSize: 11, fill: 'var(--muted-foreground)' }} axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 11, fill: 'var(--muted-foreground)' }} axisLine={false} tickLine={false} tickFormatter={(v) => formatEuroCompact(v).replace(' €', '')} width={50} />
<Tooltip
contentStyle={{ background: 'var(--popover)', border: '1px solid var(--border)', borderRadius: 'var(--radius)', fontSize: 12 }}
formatter={(v: number) => [formatEuro(v), 'Bezahlt']}
/>
<Line type="monotone" dataKey="cents" stroke="var(--foreground)" strokeWidth={2} dot={{ fill: 'var(--foreground)', r: 3 }} />
</LineChart>
</ResponsiveContainer>
<Button variant="ghost" size="sm" className="mt-2 h-7 text-xs" onClick={() => setView('audit')}>
Vollständiges Audit-Protokoll →
</Button>
</CardContent>
</Card>
</div>
</TabsContent>
</Tabs>
</div>
)
}
function MrrRow({ label, cents, total, negative }: { label: string; cents: number; total: number; negative?: boolean }) {
const pct = total > 0 ? Math.round((cents / total) * 100) : 0
return (
<div>
<div className="mb-1 flex items-center justify-between text-xs">
<span className="text-muted-foreground">{label}</span>
<span className={`tabular-nums ${negative ? 'text-muted-foreground' : ''}`}>
{negative ? '−' : ''}{formatEuro(cents)}
</span>
</div>
<div className="h-1.5 w-full overflow-hidden rounded bg-muted">
<div className={`h-full rounded ${negative ? 'bg-muted-foreground/40' : 'bg-foreground'}`} style={{ width: `${pct}%` }} />
</div>
</div>
)
}