Refine statistics comparisons

This commit is contained in:
2026-08-05 14:44:56 +02:00
parent eafd718e64
commit 4b35dfe55e
3 changed files with 177 additions and 46 deletions
+12
View File
@@ -199,6 +199,9 @@ export type StatisticsDailyBucket = {
billed_minutes: number;
non_billable_minutes: number;
open_minutes: number;
billed_sessions: number;
non_billable_sessions: number;
open_sessions: number;
crm_billed_minutes: number;
};
@@ -212,6 +215,9 @@ export type StatisticsGroup = {
billed_minutes: number;
non_billable_minutes: number;
open_minutes: number;
billed_sessions: number;
non_billable_sessions: number;
open_sessions: number;
crm_billed_minutes: number;
crm_delta_minutes: number;
};
@@ -227,6 +233,8 @@ export type StatisticsTicket = {
billed_minutes: number;
non_billable_minutes: number;
open_minutes: number;
billed_sessions: number;
non_billable_sessions: number;
open_sessions: number;
crm_billed_minutes: number;
crm_delta_minutes: number;
@@ -264,9 +272,13 @@ export type StatisticsOverview = {
billedMinutes: number;
nonBillableMinutes: number;
openMinutes: number;
billedSessions: number;
nonBillableSessions: number;
openSessions: number;
recurringMinutes: number;
manualMinutes: number;
recurringSessions: number;
manualSessions: number;
averageSessionMinutes: number;
activeDays: number;
crmBilledMinutes: number;
+130 -39
View File
@@ -17,6 +17,7 @@ import {
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import type { ReactNode } from "react";
import { toast } from "sonner";
import { Alert } from "@/components/ui/alert";
@@ -34,6 +35,13 @@ type StatisticsPageProps = {
onNavigate: (to: string) => void;
};
const trackedTextClass = "text-sky-700 dark:text-sky-300";
const teamspaceTextClass = "text-emerald-700 dark:text-emerald-300";
const trackedFillClass = "fill-sky-500/70 dark:fill-sky-400/65";
const teamspaceStrokeClass = "text-emerald-600 dark:text-emerald-300";
const trackedDotClass = "bg-sky-500 dark:bg-sky-400";
const teamspaceDotClass = "bg-emerald-500 dark:bg-emerald-400";
function workTypeLabel(workType: WorkType | undefined) {
if (workType === "support") {
return "Support";
@@ -62,6 +70,32 @@ function formatSignedMinutes(minutes: number) {
return `${minutes > 0 ? "+" : "-"}${formatMinutes(Math.abs(minutes))}`;
}
function formatTeamspaceDelta(minutes: number) {
if (minutes === 0) {
return "ausgeglichen";
}
return `TS ${formatSignedMinutes(minutes)}`;
}
function DeltaBadge({ minutes }: { minutes: number }) {
return (
<Badge variant={minutes === 0 ? "success" : "warning"}>
{formatTeamspaceDelta(minutes)}
</Badge>
);
}
function TimeComparison({ trackedMinutes, crmMinutes, sessions }: { trackedMinutes: number; crmMinutes: number; sessions?: number }) {
return (
<div className="space-y-0.5 text-sm">
<div className={`font-semibold ${trackedTextClass}`}>Sessions {formatMinutes(trackedMinutes)}</div>
<div className={`font-semibold ${teamspaceTextClass}`}>Teamspace {formatMinutes(crmMinutes)}</div>
{typeof sessions === "number" ? <div className="text-xs text-muted-foreground">{sessions} Session(s)</div> : null}
</div>
);
}
function formatAxisMinutes(value: number) {
const minutes = Math.round(value);
@@ -122,6 +156,9 @@ function buildMonthSeries(month: string, buckets: StatisticsDailyBucket[]) {
billedMinutes: bucket?.billed_minutes ?? 0,
nonBillableMinutes: bucket?.non_billable_minutes ?? 0,
openMinutes: bucket?.open_minutes ?? 0,
billedSessions: bucket?.billed_sessions ?? 0,
nonBillableSessions: bucket?.non_billable_sessions ?? 0,
openSessions: bucket?.open_sessions ?? 0,
crmBilledMinutes: bucket?.crm_billed_minutes ?? 0
};
});
@@ -171,14 +208,14 @@ function DailyEffortChart({ month, buckets }: { month: string; buckets: Statisti
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" />
<rect key={point.bucket.day} x={point.x - barWidth / 2} y={y} width={barWidth} height={point.totalBarHeight} rx="4" className={trackedFillClass} />
) : null;
})}
{crmPath ? <path d={crmPath} fill="none" stroke="currentColor" strokeWidth="2.5" className="text-foreground" /> : null}
{crmPath ? <path d={crmPath} fill="none" stroke="currentColor" strokeWidth="2.5" className={teamspaceStrokeClass} /> : 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" />
<circle key={`${point.bucket.day}-crm`} cx={point.x} cy={point.crmY} r="3" className={`fill-background ${teamspaceStrokeClass}`} stroke="currentColor" strokeWidth="2" />
))}
{points.map((point, index) =>
index % labelStep === 0 || index === points.length - 1 ? (
@@ -195,7 +232,19 @@ function DailyEffortChart({ month, buckets }: { month: string; buckets: Statisti
);
}
function MetricCard({ label, value, detail, icon: Icon }: { label: string; value: string | number; detail?: string; icon: LucideIcon }) {
function MetricCard({
label,
value,
detail,
icon: Icon,
valueClassName = ""
}: {
label: string;
value: string | number;
detail?: ReactNode;
icon: LucideIcon;
valueClassName?: string;
}) {
return (
<Card>
<CardContent className="flex items-center gap-3 p-3">
@@ -204,7 +253,7 @@ function MetricCard({ label, value, detail, icon: Icon }: { label: string; value
</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>
<p className={`truncate text-xl font-semibold tracking-normal ${valueClassName}`}>{value}</p>
{detail ? <p className="truncate text-xs text-muted-foreground">{detail}</p> : null}
</div>
</CardContent>
@@ -217,12 +266,16 @@ function OrganizationRow({ organization, maxMinutes }: { organization: Statistic
<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>
<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>{organization.sessions}</TableCell>
<TableCell>
<Badge variant={organization.crm_delta_minutes === 0 ? "success" : "warning"}>{formatSignedMinutes(organization.crm_delta_minutes)}</Badge>
<TimeComparison trackedMinutes={organization.total_minutes} crmMinutes={organization.crm_billed_minutes} />
</TableCell>
<TableCell>
<DeltaBadge minutes={organization.crm_delta_minutes} />
</TableCell>
<TableCell className="min-w-[150px]">
<Progress value={percentage(organization.total_minutes, maxMinutes)} />
@@ -240,10 +293,12 @@ function TicketHotspot({ ticket, month, onNavigate }: { ticket: StatisticsTicket
{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>
<div className="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-xs">
<span className={trackedTextClass}>Sessions {formatMinutes(ticket.total_minutes)}</span>
<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>
</div>
</div>
<Button variant="secondary" size="sm" onClick={() => onNavigate(`/analysis/month/${month}/tickets/${ticket.ticket_id}`)}>
@@ -263,9 +318,10 @@ function CrmDayItem({ item, onNavigate }: { item: StatisticsCrmDay; onNavigate:
<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 className="flex flex-wrap gap-x-3 gap-y-1 text-xs">
<span className={trackedTextClass}>Sessions {formatMinutes(item.tracked_minutes)}</span>
<span className={teamspaceTextClass}>Teamspace {formatMinutes(item.crm_billed_minutes)}</span>
{typeof item.delta_minutes === "number" ? <span className="text-muted-foreground">Differenz {formatTeamspaceDelta(item.delta_minutes)}</span> : null}
</p>
</div>
<Button variant="ghost" size="sm" onClick={() => onNavigate(`/analysis/day/${item.day}/tickets/${item.ticket_id}`)}>
@@ -381,10 +437,32 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
) : 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} />
<MetricCard
label="Getrackt"
value={formatMinutes(totals?.minutes ?? 0)}
detail={`${totals?.sessions ?? 0} Session(s), ${totals?.tickets ?? 0} Ticket(s)`}
icon={Clock3}
valueClassName={trackedTextClass}
/>
<MetricCard
label="Teamspace"
value={formatMinutes(totals?.crmBilledMinutes ?? 0)}
detail={`Differenz ${formatTeamspaceDelta(totals?.crmDeltaMinutes ?? 0)}`}
icon={TrendingUp}
valueClassName={teamspaceTextClass}
/>
<MetricCard
label="Abgerechnet"
value={formatMinutes(totals?.billedMinutes ?? 0)}
detail={`${totals?.billedSessions ?? 0} Session(s), ${billableShare}% der Zeit`}
icon={ListChecks}
/>
<MetricCard
label="Offen"
value={formatMinutes(totals?.openMinutes ?? 0)}
detail={`${totals?.openSessions ?? 0} Session(s), ${openShare}% der Zeit`}
icon={CircleAlert}
/>
</div>
<div className="grid gap-3 lg:grid-cols-[1.6fr_1fr]">
@@ -400,12 +478,12 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
<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 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 bg-foreground" />
Teamspace
<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}
</div>
@@ -435,11 +513,13 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
<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>
<p className={`font-semibold ${trackedTextClass}`}>{formatMinutes(totals?.manualMinutes ?? 0)}</p>
<p className="text-xs text-muted-foreground">{totals?.manualSessions ?? 0} Session(s)</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>
<p className={`font-semibold ${trackedTextClass}`}>{formatMinutes(totals?.recurringMinutes ?? 0)}</p>
<p className="text-xs text-muted-foreground">{totals?.recurringSessions ?? 0} Session(s)</p>
</div>
<div className="rounded-md border bg-muted/20 p-3">
<p className="text-muted-foreground">Aktive Tage</p>
@@ -454,7 +534,10 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
<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>
<p className="flex flex-wrap gap-x-3 gap-y-1 text-xs">
<span className={trackedTextClass}>Sessions {formatMinutes(topOrganization.total_minutes)}</span>
<span className={teamspaceTextClass}>Teamspace {formatMinutes(topOrganization.crm_billed_minutes)}</span>
</p>
</div>
) : null}
</CardContent>
@@ -473,9 +556,9 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
<TableHeader>
<TableRow>
<TableHead>Organisation</TableHead>
<TableHead>Zeit</TableHead>
<TableHead>Teamspace</TableHead>
<TableHead>Differenz</TableHead>
<TableHead>Sessions</TableHead>
<TableHead>Zeiten</TableHead>
<TableHead>TS-Differenz</TableHead>
<TableHead>Anteil</TableHead>
</TableRow>
</TableHeader>
@@ -494,10 +577,14 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
<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>
<DeltaBadge minutes={organization.crm_delta_minutes} />
</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 className="flex flex-wrap gap-x-3 gap-y-1 text-xs">
<span className={trackedTextClass}>Sessions {formatMinutes(organization.total_minutes)}</span>
<span className={teamspaceTextClass}>Teamspace {formatMinutes(organization.crm_billed_minutes)}</span>
<span className="text-muted-foreground">{formatTeamspaceDelta(organization.crm_delta_minutes)}</span>
</div>
</div>
))}
</div>
@@ -518,13 +605,17 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
<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>
<DeltaBadge minutes={group.crm_delta_minutes} />
</div>
<Progress value={percentage(group.total_minutes, totals?.minutes ?? 0)} />
<div className="flex flex-wrap gap-x-3 gap-y-1 text-xs">
<span className={trackedTextClass}>Sessions {formatMinutes(group.total_minutes)}</span>
<span className={teamspaceTextClass}>Teamspace {formatMinutes(group.crm_billed_minutes)}</span>
</div>
<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>
<span>Abr. {group.billed_sessions} / {formatMinutes(group.billed_minutes)}</span>
<span>Nicht {group.non_billable_sessions} / {formatMinutes(group.non_billable_minutes)}</span>
<span>Offen {group.open_sessions} / {formatMinutes(group.open_minutes)}</span>
</div>
</div>
))}
@@ -613,8 +704,8 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
<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>
<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>
@@ -622,8 +713,8 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
<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>
<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>