Add monthly statistics dashboard

This commit is contained in:
2026-08-05 14:25:08 +02:00
parent cfc4ef45a8
commit eafd718e64
5 changed files with 1091 additions and 0 deletions
+658
View File
@@ -0,0 +1,658 @@
import {
ArrowDownRight,
ArrowUpRight,
Building2,
CheckCircle2,
ChevronLeft,
ChevronRight,
CircleAlert,
Clock3,
ExternalLink,
FileWarning,
ListChecks,
RotateCcw,
Sparkles,
Ticket,
TrendingUp,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { Alert } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Progress } from "@/components/ui/progress";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { getStatisticsOverview } from "../api";
import { currentMonth, formatDate, formatDateTime, formatMinutes } from "../format";
import type { StatisticsCrmDay, StatisticsDailyBucket, StatisticsGroup, StatisticsOverview, StatisticsTicket, WorkType } from "../types";
type StatisticsPageProps = {
onNavigate: (to: string) => void;
};
function workTypeLabel(workType: WorkType | undefined) {
if (workType === "support") {
return "Support";
}
if (workType === "consulting") {
return "Consulting";
}
return "Ohne Typ";
}
function percentage(value: number, total: number) {
if (total <= 0) {
return 0;
}
return Math.max(0, Math.min(100, Math.round((value / total) * 100)));
}
function formatSignedMinutes(minutes: number) {
if (minutes === 0) {
return "ausgeglichen";
}
return `${minutes > 0 ? "+" : "-"}${formatMinutes(Math.abs(minutes))}`;
}
function formatAxisMinutes(value: number) {
const minutes = Math.round(value);
if (minutes <= 0) {
return "0";
}
if (minutes >= 60) {
return `${new Intl.NumberFormat("de-DE", { maximumFractionDigits: minutes < 600 ? 1 : 0 }).format(minutes / 60)} h`;
}
return `${minutes} min`;
}
function niceCeilMinutes(value: number) {
if (value <= 30) {
return Math.max(5, Math.ceil(value / 5) * 5);
}
if (value <= 120) {
return Math.ceil(value / 15) * 15;
}
if (value <= 480) {
return Math.ceil(value / 30) * 30;
}
return Math.ceil(value / 60) * 60;
}
function buildChartPath(points: Array<{ x: number; y: number }>) {
if (points.length === 0) {
return "";
}
if (points.length === 1) {
return `M ${points[0].x - 18} ${points[0].y} L ${points[0].x + 18} ${points[0].y}`;
}
return points.map((point, index) => `${index === 0 ? "M" : "L"} ${point.x} ${point.y}`).join(" ");
}
function buildMonthSeries(month: string, buckets: StatisticsDailyBucket[]) {
const series = new Map(buckets.map((bucket) => [bucket.day, bucket]));
const [year, monthNumber] = month.split("-").map(Number);
const daysInMonth = Number.isFinite(year) && Number.isFinite(monthNumber) ? new Date(year, monthNumber, 0).getDate() : 31;
return Array.from({ length: daysInMonth }).map((_, index) => {
const day = String(index + 1).padStart(2, "0");
const key = `${month}-${day}`;
const bucket = series.get(key);
return {
day: key,
label: day,
sessions: bucket?.sessions ?? 0,
totalMinutes: bucket?.total_minutes ?? 0,
billedMinutes: bucket?.billed_minutes ?? 0,
nonBillableMinutes: bucket?.non_billable_minutes ?? 0,
openMinutes: bucket?.open_minutes ?? 0,
crmBilledMinutes: bucket?.crm_billed_minutes ?? 0
};
});
}
function DailyEffortChart({ month, buckets }: { month: string; buckets: StatisticsDailyBucket[] }) {
const series = buildMonthSeries(month, buckets);
const width = 900;
const height = 220;
const padding = { top: 18, right: 20, bottom: 36, left: 64 };
const innerWidth = width - padding.left - padding.right;
const innerHeight = height - padding.top - padding.bottom;
const maxMinutes = niceCeilMinutes(Math.max(...series.map((bucket) => Math.max(bucket.totalMinutes, bucket.crmBilledMinutes)), 1));
const ticks = Array.from({ length: 5 }).map((_, index) => maxMinutes - (index * maxMinutes) / 4);
const labelStep = Math.max(1, Math.ceil(series.length / 9));
const barWidth = Math.max(5, Math.min(16, innerWidth / Math.max(series.length, 1) / 2));
const points = series.map((bucket, index) => {
const x = series.length === 1 ? padding.left + innerWidth / 2 : padding.left + (index * innerWidth) / (series.length - 1);
return {
bucket,
x,
totalBarHeight: bucket.totalMinutes > 0 ? Math.max(2, (bucket.totalMinutes / maxMinutes) * innerHeight) : 0,
crmY: padding.top + (1 - bucket.crmBilledMinutes / maxMinutes) * innerHeight
};
});
const crmPath = buildChartPath(points.filter((point) => point.bucket.crmBilledMinutes > 0).map((point) => ({ x: point.x, y: point.crmY })));
return (
<div className="h-56 w-full overflow-hidden rounded-md border bg-background">
{series.some((bucket) => bucket.totalMinutes > 0 || bucket.crmBilledMinutes > 0) ? (
<svg viewBox={`0 0 ${width} ${height}`} className="h-full w-full">
{ticks.map((tick) => {
const y = padding.top + (1 - tick / maxMinutes) * innerHeight;
return (
<g key={tick}>
<line x1={padding.left} x2={width - padding.right} y1={y} y2={y} stroke="currentColor" className="text-muted/70" />
<text x={padding.left - 9} y={y + 4} textAnchor="end" className="fill-muted-foreground text-[11px]">
{formatAxisMinutes(tick)}
</text>
</g>
);
})}
<line x1={padding.left} x2={padding.left} y1={padding.top} y2={padding.top + innerHeight} stroke="currentColor" className="text-muted-foreground/70" />
{points.map((point) => {
const y = padding.top + innerHeight - point.totalBarHeight;
return point.totalBarHeight > 0 ? (
<rect key={point.bucket.day} x={point.x - barWidth / 2} y={y} width={barWidth} height={point.totalBarHeight} rx="4" className="fill-neutral-300 dark:fill-neutral-700" />
) : null;
})}
{crmPath ? <path d={crmPath} fill="none" stroke="currentColor" strokeWidth="2.5" className="text-foreground" /> : null}
{points
.filter((point) => point.bucket.crmBilledMinutes > 0)
.map((point) => (
<circle key={`${point.bucket.day}-crm`} cx={point.x} cy={point.crmY} r="3" className="fill-background stroke-foreground" strokeWidth="2" />
))}
{points.map((point, index) =>
index % labelStep === 0 || index === points.length - 1 ? (
<text key={`${point.bucket.day}-label`} x={point.x} y={height - 11} textAnchor="middle" className="fill-muted-foreground text-[11px]">
{point.bucket.label}
</text>
) : null
)}
</svg>
) : (
<div className="grid h-full place-items-center text-sm text-muted-foreground">Keine Statistikdaten für diesen Monat.</div>
)}
</div>
);
}
function MetricCard({ label, value, detail, icon: Icon }: { label: string; value: string | number; detail?: string; icon: LucideIcon }) {
return (
<Card>
<CardContent className="flex items-center gap-3 p-3">
<div className="grid size-8 shrink-0 place-items-center rounded-lg border bg-muted/40 text-muted-foreground">
<Icon className="size-3.5" />
</div>
<div className="min-w-0">
<p className="text-xs font-medium text-muted-foreground">{label}</p>
<p className="truncate text-xl font-semibold tracking-normal">{value}</p>
{detail ? <p className="truncate text-xs text-muted-foreground">{detail}</p> : null}
</div>
</CardContent>
</Card>
);
}
function OrganizationRow({ organization, maxMinutes }: { organization: StatisticsGroup; maxMinutes: number }) {
return (
<TableRow>
<TableCell className="max-w-[320px] whitespace-normal">
<div className="font-medium">{organization.organization_name ?? "Keine Organisation"}</div>
<div className="text-xs text-muted-foreground">{organization.tickets} Ticket(s) · {organization.sessions} Session(s)</div>
</TableCell>
<TableCell>{formatMinutes(organization.total_minutes)}</TableCell>
<TableCell>{formatMinutes(organization.crm_billed_minutes)}</TableCell>
<TableCell>
<Badge variant={organization.crm_delta_minutes === 0 ? "success" : "warning"}>{formatSignedMinutes(organization.crm_delta_minutes)}</Badge>
</TableCell>
<TableCell className="min-w-[150px]">
<Progress value={percentage(organization.total_minutes, maxMinutes)} />
</TableCell>
</TableRow>
);
}
function TicketHotspot({ ticket, month, onNavigate }: { ticket: StatisticsTicket; month: string; onNavigate: (to: string) => void }) {
return (
<div className="grid gap-2 rounded-md border bg-background p-3 sm:grid-cols-[1fr_auto] sm:items-center">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="font-semibold">{ticket.ticket_number}</span>
{ticket.open_sessions > 0 ? <Badge variant="warning">{ticket.open_sessions} offen</Badge> : <Badge variant="success">bewertet</Badge>}
</div>
<p className="truncate text-sm text-muted-foreground">{ticket.organization_name ?? "Keine Organisation"}</p>
<div className="mt-1 flex flex-wrap gap-2 text-xs text-muted-foreground">
<span>{formatMinutes(ticket.total_minutes)} getrackt</span>
<span>Teamspace {formatMinutes(ticket.crm_billed_minutes)}</span>
<span>{ticket.active_days} Tag(e)</span>
</div>
</div>
<Button variant="secondary" size="sm" onClick={() => onNavigate(`/analysis/month/${month}/tickets/${ticket.ticket_id}`)}>
Öffnen
<ExternalLink className="size-4" />
</Button>
</div>
);
}
function CrmDayItem({ item, onNavigate }: { item: StatisticsCrmDay; onNavigate: (to: string) => void }) {
return (
<div className="grid gap-2 rounded-md border bg-background p-3 sm:grid-cols-[1fr_auto] sm:items-center">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{item.ticket_number}</span>
<Badge variant="outline">{formatDate(`${item.day}T00:00:00`)}</Badge>
</div>
<p className="truncate text-sm text-muted-foreground">{item.organization_name}</p>
<p className="text-xs text-muted-foreground">
Getrackt {formatMinutes(item.tracked_minutes)} · Teamspace {formatMinutes(item.crm_billed_minutes)}
{typeof item.delta_minutes === "number" ? ` · Differenz ${formatSignedMinutes(item.delta_minutes)}` : ""}
</p>
</div>
<Button variant="ghost" size="sm" onClick={() => onNavigate(`/analysis/day/${item.day}/tickets/${item.ticket_id}`)}>
Tag öffnen
<ExternalLink className="size-4" />
</Button>
</div>
);
}
export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
const [month, setMonth] = useState(currentMonth());
const [stats, setStats] = useState<StatisticsOverview | null>(null);
const [loading, setLoading] = useState(false);
const loadRequestId = useRef(0);
async function load() {
const requestId = loadRequestId.current + 1;
loadRequestId.current = requestId;
setLoading(true);
try {
const result = await getStatisticsOverview(month);
if (requestId === loadRequestId.current) {
setStats(result);
}
} catch (error) {
if (requestId === loadRequestId.current) {
toast.error("Statistiken konnten nicht geladen werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
}
} finally {
if (requestId === loadRequestId.current) {
setLoading(false);
}
}
}
useEffect(() => {
void load();
}, [month]);
useEffect(() => {
function refreshVisible() {
if (document.visibilityState === "visible") {
void load();
}
}
const interval = window.setInterval(refreshVisible, 60_000);
window.addEventListener("focus", refreshVisible);
document.addEventListener("visibilitychange", refreshVisible);
return () => {
window.clearInterval(interval);
window.removeEventListener("focus", refreshVisible);
document.removeEventListener("visibilitychange", refreshVisible);
};
}, [month]);
function changeMonthBy(delta: number) {
const [year, monthNumber] = month.split("-").map(Number);
const date = Number.isFinite(year) && Number.isFinite(monthNumber) ? new Date(year, monthNumber - 1, 1) : new Date();
date.setMonth(date.getMonth() + delta);
setMonth(`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`);
}
const totals = stats?.totals;
const billableShare = percentage(totals?.billedMinutes ?? 0, totals?.minutes ?? 0);
const openShare = percentage(totals?.openMinutes ?? 0, totals?.minutes ?? 0);
const crmCoverage = percentage(totals?.crmBilledMinutes ?? 0, totals?.minutes ?? 0);
const maxOrganizationMinutes = Math.max(...(stats?.organizations ?? []).map((organization) => organization.total_minutes), 1);
const topOrganization = stats?.organizations[0] ?? null;
const strongestDay = useMemo(() => {
return [...(stats?.dailySeries ?? [])].sort((left, right) => right.total_minutes - left.total_minutes)[0] ?? null;
}, [stats?.dailySeries]);
const attentionCount =
(stats?.attention.openSessions.length ?? 0) +
(stats?.attention.missingCrmDays.length ?? 0) +
(stats?.attention.crmMismatches.length ?? 0);
return (
<div className="space-y-5">
<div className="flex flex-col gap-3 xl:flex-row xl:items-end xl:justify-between">
<div>
<h2 className="text-2xl font-semibold tracking-normal">Statistiken</h2>
<p className="text-sm text-muted-foreground">Monatswerte, Teamspace-Abgleich, Organisationen und offene Nacharbeit.</p>
</div>
<div className="grid gap-1 sm:w-[300px]">
<div className="flex items-center justify-between gap-2">
<label className="text-xs font-medium text-muted-foreground" htmlFor="statistics-month">Monat</label>
{loading ? <span className="text-xs text-muted-foreground">lädt...</span> : null}
</div>
<div className="grid grid-cols-[2.25rem_minmax(0,1fr)_2.25rem] gap-1">
<Button type="button" size="icon" variant="secondary" className="h-9 w-9" onClick={() => changeMonthBy(-1)} aria-label="Monat zurück">
<ChevronLeft className="size-4" />
</Button>
<Input id="statistics-month" className="h-9" type="month" value={month} onChange={(event) => setMonth(event.currentTarget.value)} />
<Button type="button" size="icon" variant="secondary" className="h-9 w-9" onClick={() => changeMonthBy(1)} aria-label="Monat vor">
<ChevronRight className="size-4" />
</Button>
</div>
</div>
</div>
{stats?.closed ? (
<Alert variant="success" className="flex items-center gap-2 py-3">
<CheckCircle2 className="size-4 shrink-0" />
<span>Dieser Monat wurde am {formatDateTime(stats.closedAt!)} abgeschlossen.</span>
</Alert>
) : null}
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<MetricCard label="Getrackt" value={formatMinutes(totals?.minutes ?? 0)} detail={`${totals?.sessions ?? 0} Session(s), ${totals?.tickets ?? 0} Ticket(s)`} icon={Clock3} />
<MetricCard label="Teamspace" value={formatMinutes(totals?.crmBilledMinutes ?? 0)} detail={`Differenz ${formatSignedMinutes(totals?.crmDeltaMinutes ?? 0)}`} icon={TrendingUp} />
<MetricCard label="Abgerechnet" value={formatMinutes(totals?.billedMinutes ?? 0)} detail={`${billableShare}% der getrackten Zeit`} icon={ListChecks} />
<MetricCard label="Offen" value={formatMinutes(totals?.openMinutes ?? 0)} detail={`${totals?.openSessions ?? 0} Session(s), ${openShare}%`} icon={CircleAlert} />
</div>
<div className="grid gap-3 lg:grid-cols-[1.6fr_1fr]">
<Card>
<CardHeader className="gap-3 p-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
<div>
<CardTitle>Monatsverlauf</CardTitle>
<CardDescription>Getrackter Aufwand pro Tag mit Teamspace-Linie.</CardDescription>
</div>
<Badge variant="outline">{crmCoverage}% Teamspace-Abdeckung</Badge>
</CardHeader>
<CardContent className="px-4 pb-4">
<DailyEffortChart month={month} buckets={stats?.dailySeries ?? []} />
<div className="mt-3 flex flex-wrap gap-3 text-xs text-muted-foreground">
<span className="inline-flex items-center gap-1.5">
<span className="size-2 rounded-sm bg-neutral-300 dark:bg-neutral-700" />
Getrackt
</span>
<span className="inline-flex items-center gap-1.5">
<span className="h-0.5 w-4 bg-foreground" />
Teamspace
</span>
{strongestDay ? <span>Stärkster Tag: {formatDate(`${strongestDay.day}T00:00:00`)} mit {formatMinutes(strongestDay.total_minutes)}</span> : null}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="p-4">
<CardTitle>Abrechnungsqualität</CardTitle>
<CardDescription>Bewertung, CRM-Abdeckung und Erfassungsart.</CardDescription>
</CardHeader>
<CardContent className="space-y-4 px-4 pb-4">
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Abgerechnet</span>
<span className="font-medium">{billableShare}%</span>
</div>
<Progress value={billableShare} />
</div>
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Teamspace</span>
<span className="font-medium">{crmCoverage}%</span>
</div>
<Progress value={crmCoverage} />
</div>
<div className="grid grid-cols-2 gap-2 text-sm">
<div className="rounded-md border bg-muted/20 p-3">
<p className="text-muted-foreground">Manuell</p>
<p className="font-semibold">{formatMinutes(totals?.manualMinutes ?? 0)}</p>
</div>
<div className="rounded-md border bg-muted/20 p-3">
<p className="text-muted-foreground">Fix</p>
<p className="font-semibold">{formatMinutes(totals?.recurringMinutes ?? 0)}</p>
</div>
<div className="rounded-md border bg-muted/20 p-3">
<p className="text-muted-foreground">Aktive Tage</p>
<p className="font-semibold">{totals?.activeDays ?? 0}</p>
</div>
<div className="rounded-md border bg-muted/20 p-3">
<p className="text-muted-foreground">Schnitt</p>
<p className="font-semibold">{formatMinutes(totals?.averageSessionMinutes ?? 0)}</p>
</div>
</div>
{topOrganization ? (
<div className="rounded-md border bg-muted/20 p-3 text-sm">
<p className="text-muted-foreground">Top Organisation</p>
<p className="truncate font-semibold">{topOrganization.organization_name}</p>
<p className="text-xs text-muted-foreground">{formatMinutes(topOrganization.total_minutes)}</p>
</div>
) : null}
</CardContent>
</Card>
</div>
<div className="grid gap-3 xl:grid-cols-[1.45fr_0.8fr]">
<Card>
<CardHeader className="p-4">
<CardTitle>Statistik pro Organisation</CardTitle>
<CardDescription>Kundenaufwand inklusive Teamspace-Differenz.</CardDescription>
</CardHeader>
<CardContent className="px-4 pb-4">
<div className="hidden md:block">
<Table>
<TableHeader>
<TableRow>
<TableHead>Organisation</TableHead>
<TableHead>Zeit</TableHead>
<TableHead>Teamspace</TableHead>
<TableHead>Differenz</TableHead>
<TableHead>Anteil</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(stats?.organizations ?? []).map((organization) => (
<OrganizationRow key={organization.organization_id ?? organization.organization_name} organization={organization} maxMinutes={maxOrganizationMinutes} />
))}
</TableBody>
</Table>
</div>
<div className="space-y-2 md:hidden">
{(stats?.organizations ?? []).map((organization) => (
<div key={organization.organization_id ?? organization.organization_name} className="space-y-2 rounded-md border bg-background p-3">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate font-medium">{organization.organization_name ?? "Keine Organisation"}</p>
<p className="text-xs text-muted-foreground">{organization.tickets} Ticket(s) · {organization.sessions} Session(s)</p>
</div>
<Badge variant={organization.crm_delta_minutes === 0 ? "success" : "warning"}>{formatSignedMinutes(organization.crm_delta_minutes)}</Badge>
</div>
<Progress value={percentage(organization.total_minutes, maxOrganizationMinutes)} />
<p className="text-xs text-muted-foreground">{formatMinutes(organization.total_minutes)} getrackt · Teamspace {formatMinutes(organization.crm_billed_minutes)}</p>
</div>
))}
</div>
{stats && stats.organizations.length === 0 ? <p className="py-8 text-center text-sm text-muted-foreground">Keine Organisationen im gewählten Monat.</p> : null}
</CardContent>
</Card>
<Card>
<CardHeader className="p-4">
<CardTitle>Typen</CardTitle>
<CardDescription>Support und Consulting im direkten Vergleich.</CardDescription>
</CardHeader>
<CardContent className="space-y-3 px-4 pb-4">
{(stats?.workTypes ?? []).map((group) => (
<div key={group.work_type ?? "none"} className="space-y-2 rounded-md border bg-background p-3">
<div className="flex items-center justify-between gap-3">
<div>
<p className="font-semibold">{workTypeLabel(group.work_type)}</p>
<p className="text-xs text-muted-foreground">{group.tickets} Ticket(s), {group.sessions} Session(s)</p>
</div>
<Badge variant="outline">{formatMinutes(group.total_minutes)}</Badge>
</div>
<Progress value={percentage(group.total_minutes, totals?.minutes ?? 0)} />
<div className="grid grid-cols-3 gap-2 text-xs text-muted-foreground">
<span>Abr. {formatMinutes(group.billed_minutes)}</span>
<span>Nicht {formatMinutes(group.non_billable_minutes)}</span>
<span>Offen {formatMinutes(group.open_minutes)}</span>
</div>
</div>
))}
{stats && stats.workTypes.length === 0 ? <p className="py-8 text-center text-sm text-muted-foreground">Noch keine Typdaten vorhanden.</p> : null}
</CardContent>
</Card>
</div>
<div className="grid gap-3 xl:grid-cols-2">
<Card>
<CardHeader className="gap-3 p-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
<div>
<CardTitle>Ticket-Hotspots</CardTitle>
<CardDescription>Die größten Zeitblöcke im Monat.</CardDescription>
</div>
<Button variant="secondary" size="sm" onClick={() => onNavigate("/analysis")}>
Auswertung öffnen
<ExternalLink className="size-4" />
</Button>
</CardHeader>
<CardContent className="space-y-2 px-4 pb-4">
{(stats?.tickets ?? []).map((ticket) => (
<TicketHotspot key={ticket.ticket_id} ticket={ticket} month={month} onNavigate={onNavigate} />
))}
{stats && stats.tickets.length === 0 ? <p className="py-8 text-center text-sm text-muted-foreground">Keine Tickets im gewählten Monat.</p> : null}
</CardContent>
</Card>
<Card>
<CardHeader className="gap-3 p-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
<div>
<CardTitle>Aufräumen</CardTitle>
<CardDescription>Alles, was vor dem Monatsabschluss Aufmerksamkeit braucht.</CardDescription>
</div>
<Badge variant={attentionCount > 0 ? "warning" : "success"}>{attentionCount} Punkt(e)</Badge>
</CardHeader>
<CardContent className="space-y-4 px-4 pb-4">
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm font-medium">
<FileWarning className="size-4 text-muted-foreground" />
Offene Bewertungen
</div>
{(stats?.attention.openSessions ?? []).slice(0, 5).map((session) => (
<div key={session.id} className="grid gap-2 rounded-md border bg-background p-3 sm:grid-cols-[1fr_auto] sm:items-center">
<div className="min-w-0">
<p className="truncate font-medium">{session.ticket_number}</p>
<p className="truncate text-sm text-muted-foreground">{session.organization_name}</p>
<p className="truncate text-xs text-muted-foreground">{session.activity}</p>
</div>
<Button variant="ghost" size="sm" onClick={() => onNavigate(`/analysis/month/${month}/tickets/${session.ticket_id}`)}>
Öffnen
<ExternalLink className="size-4" />
</Button>
</div>
))}
{stats?.attention.openSessions.length === 0 ? <p className="rounded-md border bg-muted/20 p-3 text-sm text-muted-foreground">Keine offenen Bewertungen.</p> : null}
</div>
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm font-medium">
<Sparkles className="size-4 text-muted-foreground" />
Tage ohne Teamspace-Wert
</div>
{(stats?.attention.missingCrmDays ?? []).slice(0, 4).map((item) => (
<CrmDayItem key={`${item.ticket_id}-${item.day}-missing`} item={item} onNavigate={onNavigate} />
))}
{stats?.attention.missingCrmDays.length === 0 ? <p className="rounded-md border bg-muted/20 p-3 text-sm text-muted-foreground">Alle getrackten Tage haben einen Teamspace-Wert.</p> : null}
</div>
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm font-medium">
<ArrowUpRight className="size-4 text-muted-foreground" />
Auffällige Differenzen
</div>
{(stats?.attention.crmMismatches ?? []).slice(0, 4).map((item) => (
<CrmDayItem key={`${item.ticket_id}-${item.day}-mismatch`} item={item} onNavigate={onNavigate} />
))}
{stats?.attention.crmMismatches.length === 0 ? <p className="rounded-md border bg-muted/20 p-3 text-sm text-muted-foreground">Keine Abweichungen zwischen Tracking und Teamspace.</p> : null}
</div>
</CardContent>
</Card>
</div>
<div className="grid gap-3 sm:grid-cols-3">
<Card>
<CardContent className="flex items-center gap-3 p-3">
<ArrowUpRight className="size-4 text-muted-foreground" />
<div>
<p className="text-xs text-muted-foreground">Mehr getrackt als Teamspace</p>
<p className="font-semibold">{formatMinutes(Math.max(totals?.crmDeltaMinutes ?? 0, 0))}</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-3 p-3">
<ArrowDownRight className="size-4 text-muted-foreground" />
<div>
<p className="text-xs text-muted-foreground">Mehr Teamspace als getrackt</p>
<p className="font-semibold">{formatMinutes(Math.max(-(totals?.crmDeltaMinutes ?? 0), 0))}</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-3 p-3">
<Building2 className="size-4 text-muted-foreground" />
<div>
<p className="text-xs text-muted-foreground">Organisationen</p>
<p className="font-semibold">{stats?.organizations.length ?? 0}</p>
</div>
</CardContent>
</Card>
</div>
{stats && stats.totals.sessions === 0 ? (
<Card>
<CardContent className="grid place-items-center gap-2 p-8 text-center">
<Ticket className="size-8 text-muted-foreground" />
<div>
<p className="font-medium">Keine Sessions in diesem Monat</p>
<p className="text-sm text-muted-foreground">Sobald Sessions vorhanden sind, füllt sich die Statistik automatisch.</p>
</div>
<Button variant="secondary" size="sm" onClick={() => setMonth(currentMonth())}>
<RotateCcw className="size-4" />
Aktuellen Monat zeigen
</Button>
</CardContent>
</Card>
) : null}
</div>
);
}