Refine statistics Teamspace display
This commit is contained in:
@@ -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" }) {
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user