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