Refine statistics comparisons
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user