Refine statistics Teamspace display

This commit is contained in:
2026-08-05 15:34:47 +02:00
parent 84057771fe
commit 8e4f07dcf8
2 changed files with 57 additions and 127 deletions
+1 -5
View File
@@ -39,11 +39,7 @@ function formatSignedMinutes(minutes: number) {
}
function formatTeamspaceDelta(minutes: number) {
if (minutes === 0) {
return "ausgeglichen";
}
return `TS ${formatSignedMinutes(minutes)}`;
return formatSignedMinutes(minutes);
}
function StatusCard({ label, value, detail, tone }: { label: string; value: string | number; detail: string; tone?: "ok" | "warn" | "neutral" }) {
+46 -112
View File
@@ -1,7 +1,4 @@
import {
ArrowDownRight,
ArrowUpRight,
Building2,
ChevronLeft,
ChevronRight,
CircleAlert,
@@ -97,16 +94,16 @@ function formatSignedMinutes(minutes: number) {
}
function formatTeamspaceDelta(minutes: number) {
if (minutes === 0) {
return "ausgeglichen";
return formatSignedMinutes(minutes);
}
return `TS ${formatSignedMinutes(minutes)}`;
function deltaTextClass(minutes: number) {
return minutes < 0 ? "text-red-700 dark:text-red-300" : "text-emerald-700 dark:text-emerald-300";
}
function DeltaBadge({ minutes }: { minutes: number }) {
return (
<Badge variant={minutes === 0 ? "success" : "warning"}>
<Badge variant={minutes < 0 ? "destructive" : "success"}>
{formatTeamspaceDelta(minutes)}
</Badge>
);
@@ -134,40 +131,35 @@ function TimeComparison({ trackedMinutes, crmMinutes, sessions }: { trackedMinut
);
}
function teamspaceTrend(buckets: StatisticsDailyBucket[]) {
const activeBuckets = buckets
.filter((bucket) => bucket.total_minutes > 0 || bucket.crm_billed_minutes > 0)
.map((bucket) => ({
day: bucket.day,
deltaMinutes: bucket.crm_billed_minutes - bucket.total_minutes
}));
if (activeBuckets.length < 2) {
return {
label: "Trend noch offen",
detail: "Zu wenige Tageswerte",
changeMinutes: 0,
firstAverage: 0,
secondAverage: 0
};
}
const splitIndex = Math.ceil(activeBuckets.length / 2);
const first = activeBuckets.slice(0, splitIndex);
const second = activeBuckets.slice(splitIndex);
const average = (items: typeof activeBuckets) => Math.round(items.reduce((sum, item) => sum + item.deltaMinutes, 0) / Math.max(items.length, 1));
const firstAverage = average(first);
const secondAverage = average(second.length > 0 ? second : first);
const changeMinutes = secondAverage - firstAverage;
const label = Math.abs(changeMinutes) < 15 ? "Trend stabil" : changeMinutes > 0 ? "Teamspace steigt relativ" : "Teamspace faellt relativ";
return {
label,
detail: `${formatTeamspaceDelta(firstAverage)} zu ${formatTeamspaceDelta(secondAverage)}`,
changeMinutes,
firstAverage,
secondAverage
};
function ColorLegend() {
return (
<div className="flex flex-wrap gap-x-4 gap-y-2 rounded-md border bg-muted/20 p-3 text-xs text-muted-foreground">
<span className="inline-flex items-center gap-1.5">
<span className={`size-2 rounded-sm ${trackedDotClass}`} />
<span className={trackedTextClass}>Sessions</span>
</span>
<span className="inline-flex items-center gap-1.5">
<span className={`h-0.5 w-4 ${teamspaceDotClass}`} />
<span className={teamspaceTextClass}>Teamspace</span>
</span>
<span className="inline-flex items-center gap-1.5">
<span className="size-2 rounded-full bg-emerald-500 dark:bg-emerald-400" />
<span className="text-emerald-700 dark:text-emerald-300">Differenz plus oder ausgeglichen</span>
</span>
<span className="inline-flex items-center gap-1.5">
<span className="size-2 rounded-full bg-red-500 dark:bg-red-400" />
<span className="text-red-700 dark:text-red-300">Differenz minus</span>
</span>
<span className="inline-flex items-center gap-1.5">
<span className="size-2 rounded-full bg-violet-500 dark:bg-violet-400" />
<span className={evaluatedTextClass}>bewertet</span>
</span>
<span className="inline-flex items-center gap-1.5">
<span className="size-2 rounded-full bg-amber-500 dark:bg-amber-400" />
<span className={openTextClass}>offen</span>
</span>
</div>
);
}
function formatAxisMinutes(value: number) {
@@ -375,7 +367,7 @@ function TicketHotspot({ ticket, month, onNavigate }: { ticket: StatisticsTicket
<span className={teamspaceTextClass}>Teamspace {formatMinutes(ticket.crm_billed_minutes)}</span>
<span className="text-muted-foreground">{ticket.sessions} Session(s)</span>
<span>{ticket.active_days} Tag(e)</span>
<span className="text-muted-foreground">{formatTeamspaceDelta(ticket.crm_delta_minutes)}</span>
<span className={deltaTextClass(ticket.crm_delta_minutes)}>{formatTeamspaceDelta(ticket.crm_delta_minutes)}</span>
</div>
</div>
<Button variant="secondary" size="sm" onClick={() => onNavigate(`/analysis/month/${month}/tickets/${ticket.ticket_id}`)}>
@@ -457,7 +449,6 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
const strongestDay = useMemo(() => {
return [...(stats?.dailySeries ?? [])].sort((left, right) => right.total_minutes - left.total_minutes)[0] ?? null;
}, [stats?.dailySeries]);
const trend = useMemo(() => teamspaceTrend(stats?.dailySeries ?? []), [stats?.dailySeries]);
const organizationPageCount = Math.max(1, Math.ceil((stats?.organizations.length ?? 0) / organizationPageSize));
const safeOrganizationPage = Math.min(organizationPage, organizationPageCount);
const organizationStart = (safeOrganizationPage - 1) * organizationPageSize;
@@ -508,7 +499,7 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
<MetricCard
label="Teamspace"
value={formatMinutes(totals?.crmBilledMinutes ?? 0)}
detail={`Differenz ${formatTeamspaceDelta(totals?.crmDeltaMinutes ?? 0)}`}
detail={<span className={deltaTextClass(totals?.crmDeltaMinutes ?? 0)}>Differenz {formatTeamspaceDelta(totals?.crmDeltaMinutes ?? 0)}</span>}
icon={TrendingUp}
valueClassName={teamspaceTextClass}
/>
@@ -538,37 +529,22 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
/>
</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>
<CardTitle>Monatsverlauf und Abrechnungsqualität</CardTitle>
<CardDescription>Getrackter Aufwand pro Tag, Teamspace-Linie und Bewertungsstand in einem Überblick.</CardDescription>
</div>
<Badge variant="outline">{crmCoverage}% Teamspace-Abdeckung</Badge>
</CardHeader>
<CardContent className="px-4 pb-4">
<CardContent className="grid gap-4 px-4 pb-4 xl:grid-cols-[1.55fr_1fr]">
<div className="space-y-3">
<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 ${trackedDotClass}`} />
<span className={trackedTextClass}>Sessions</span>
</span>
<span className="inline-flex items-center gap-1.5">
<span className={`h-0.5 w-4 ${teamspaceDotClass}`} />
<span className={teamspaceTextClass}>Teamspace</span>
</span>
{strongestDay ? <span>Stärkster Tag: {formatDate(`${strongestDay.day}T00:00:00`)} mit {formatMinutes(strongestDay.total_minutes)}</span> : null}
<ColorLegend />
{strongestDay ? <p className="text-xs text-muted-foreground">Stärkster Tag: {formatDate(`${strongestDay.day}T00:00:00`)} mit {formatMinutes(strongestDay.total_minutes)}</p> : null}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="p-4">
<CardTitle>Abrechnungsqualität</CardTitle>
<CardDescription>Bewertungsstand, Teamspace-Abgleich und Entwicklung im Monat.</CardDescription>
</CardHeader>
<CardContent className="space-y-4 px-4 pb-4">
<div className="space-y-4">
<div className="space-y-2 rounded-md border bg-muted/20 p-3">
<div className="flex items-center justify-between gap-3 text-sm">
<span className="text-muted-foreground">Bewertungsstand</span>
@@ -578,7 +554,7 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
<div className="bg-violet-500 dark:bg-violet-400" style={{ width: `${evaluatedSessionShare}%` }} />
<div className="bg-amber-500 dark:bg-amber-400" style={{ width: `${openSessionShare}%` }} />
</div>
<div className="grid gap-2 text-xs sm:grid-cols-2">
<div className="grid gap-2 text-xs sm:grid-cols-2 xl:grid-cols-1 2xl:grid-cols-2">
<div className={evaluatedTextClass}>Bewertet: {evaluatedSessions} Session(s), {formatMinutes(evaluatedMinutes)}</div>
<div className={openTextClass}>Offen: {totals?.openSessions ?? 0} Session(s), {formatMinutes(totals?.openMinutes ?? 0)}</div>
</div>
@@ -596,18 +572,6 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
<div className="text-xs text-muted-foreground">Teamspace-Abdeckung: {crmCoverage}% der getrackten Session-Zeit</div>
</div>
<div className="rounded-md border bg-muted/20 p-3 text-sm">
<div className="flex items-start justify-between gap-3">
<div>
<p className="font-medium">{trend.label}</p>
<p className="text-xs text-muted-foreground">{trend.detail}</p>
</div>
<Badge variant={Math.abs(trend.changeMinutes) < 15 ? "outline" : "warning"}>
{formatSignedMinutes(trend.changeMinutes)}
</Badge>
</div>
</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>
@@ -638,9 +602,9 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
</p>
</div>
) : null}
</div>
</CardContent>
</Card>
</div>
<div className="grid gap-3 xl:grid-cols-[1.45fr_0.8fr]">
<Card>
@@ -661,7 +625,7 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
<TableHead>Organisation</TableHead>
<TableHead>Sessions</TableHead>
<TableHead>Zeiten</TableHead>
<TableHead>TS-Differenz</TableHead>
<TableHead>Differenz</TableHead>
<TableHead>Anteil am Monat</TableHead>
</TableRow>
</TableHeader>
@@ -692,7 +656,7 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
<span className={trackedTextClass}>Sessions {formatMinutes(organization.total_minutes)}</span>
<span className={teamspaceTextClass}>Teamspace {formatMinutes(organization.crm_billed_minutes)}</span>
<span className="text-muted-foreground">{percentage(organization.total_minutes, totals?.minutes ?? 0)}% vom Monat</span>
<span className="text-muted-foreground">{formatTeamspaceDelta(organization.crm_delta_minutes)}</span>
<span className={deltaTextClass(organization.crm_delta_minutes)}>{formatTeamspaceDelta(organization.crm_delta_minutes)}</span>
</div>
</div>
))}
@@ -787,36 +751,6 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
</CardContent>
</Card>
<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 Teamspace als Sessions</p>
<p className={`font-semibold ${teamspaceTextClass}`}>{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">Teamspace zu wenig</p>
<p className={`font-semibold ${trackedTextClass}`}>{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">