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