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
+35 -7
View File
@@ -721,6 +721,8 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string) {
SUM(sb.rounded_minutes) FILTER (WHERE sb.billing_status = 'billed')::int AS billed_minutes, SUM(sb.rounded_minutes) FILTER (WHERE sb.billing_status = 'billed')::int AS billed_minutes,
SUM(sb.rounded_minutes) FILTER (WHERE sb.billing_status = 'non_billable')::int AS non_billable_minutes, SUM(sb.rounded_minutes) FILTER (WHERE sb.billing_status = 'non_billable')::int AS non_billable_minutes,
SUM(sb.rounded_minutes) FILTER (WHERE sb.billing_status IS NULL)::int AS open_minutes, SUM(sb.rounded_minutes) FILTER (WHERE sb.billing_status IS NULL)::int AS open_minutes,
COUNT(*) FILTER (WHERE sb.billing_status = 'billed')::int AS billed_sessions,
COUNT(*) FILTER (WHERE sb.billing_status = 'non_billable')::int AS non_billable_sessions,
COUNT(*) FILTER (WHERE sb.billing_status IS NULL)::int AS open_count, COUNT(*) FILTER (WHERE sb.billing_status IS NULL)::int AS open_count,
COUNT(*) FILTER (WHERE sb.recurring_billing_id IS NOT NULL)::int AS recurring_session_count, COUNT(*) FILTER (WHERE sb.recurring_billing_id IS NOT NULL)::int AS recurring_session_count,
COUNT(*) FILTER (WHERE sb.recurring_billing_id IS NULL)::int AS manual_session_count COUNT(*) FILTER (WHERE sb.recurring_billing_id IS NULL)::int AS manual_session_count
@@ -759,9 +761,13 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string) {
COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status = 'billed'), 0)::int AS billed_minutes, COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status = 'billed'), 0)::int AS billed_minutes,
COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status = 'non_billable'), 0)::int AS non_billable_minutes, COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status = 'non_billable'), 0)::int AS non_billable_minutes,
COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status IS NULL), 0)::int AS open_minutes, COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status IS NULL), 0)::int AS open_minutes,
COUNT(*) FILTER (WHERE billing_status = 'billed')::int AS billed_sessions,
COUNT(*) FILTER (WHERE billing_status = 'non_billable')::int AS non_billable_sessions,
COUNT(*) FILTER (WHERE billing_status IS NULL)::int AS open_sessions, COUNT(*) FILTER (WHERE billing_status IS NULL)::int AS open_sessions,
COALESCE(SUM(rounded_minutes) FILTER (WHERE recurring_billing_id IS NOT NULL), 0)::int AS recurring_minutes, COALESCE(SUM(rounded_minutes) FILTER (WHERE recurring_billing_id IS NOT NULL), 0)::int AS recurring_minutes,
COALESCE(SUM(rounded_minutes) FILTER (WHERE recurring_billing_id IS NULL), 0)::int AS manual_minutes, COALESCE(SUM(rounded_minutes) FILTER (WHERE recurring_billing_id IS NULL), 0)::int AS manual_minutes,
COUNT(*) FILTER (WHERE recurring_billing_id IS NOT NULL)::int AS recurring_sessions,
COUNT(*) FILTER (WHERE recurring_billing_id IS NULL)::int AS manual_sessions,
COALESCE(ROUND(AVG(rounded_minutes)), 0)::int AS average_session_minutes, COALESCE(ROUND(AVG(rounded_minutes)), 0)::int AS average_session_minutes,
COUNT(DISTINCT day)::int AS active_days, COUNT(DISTINCT day)::int AS active_days,
COALESCE((SELECT SUM(crm_billed_minutes)::int FROM ticket_day_billing), 0)::int AS crm_billed_minutes COALESCE((SELECT SUM(crm_billed_minutes)::int FROM ticket_day_billing), 0)::int AS crm_billed_minutes
@@ -779,6 +785,9 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string) {
COALESCE(day_sessions.billed_minutes, 0)::int AS billed_minutes, COALESCE(day_sessions.billed_minutes, 0)::int AS billed_minutes,
COALESCE(day_sessions.non_billable_minutes, 0)::int AS non_billable_minutes, COALESCE(day_sessions.non_billable_minutes, 0)::int AS non_billable_minutes,
COALESCE(day_sessions.open_minutes, 0)::int AS open_minutes, COALESCE(day_sessions.open_minutes, 0)::int AS open_minutes,
COALESCE(day_sessions.billed_sessions, 0)::int AS billed_sessions,
COALESCE(day_sessions.non_billable_sessions, 0)::int AS non_billable_sessions,
COALESCE(day_sessions.open_sessions, 0)::int AS open_sessions,
COALESCE(SUM(tdb.crm_billed_minutes), 0)::int AS crm_billed_minutes COALESCE(SUM(tdb.crm_billed_minutes), 0)::int AS crm_billed_minutes
FROM ( FROM (
SELECT day, SUM(tracked_minutes)::int AS tracked_minutes SELECT day, SUM(tracked_minutes)::int AS tracked_minutes
@@ -791,12 +800,15 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string) {
SUM(session_count)::int AS session_count, SUM(session_count)::int AS session_count,
SUM(COALESCE(billed_minutes, 0))::int AS billed_minutes, SUM(COALESCE(billed_minutes, 0))::int AS billed_minutes,
SUM(COALESCE(non_billable_minutes, 0))::int AS non_billable_minutes, SUM(COALESCE(non_billable_minutes, 0))::int AS non_billable_minutes,
SUM(COALESCE(open_minutes, 0))::int AS open_minutes SUM(COALESCE(open_minutes, 0))::int AS open_minutes,
SUM(billed_sessions)::int AS billed_sessions,
SUM(non_billable_sessions)::int AS non_billable_sessions,
SUM(open_count)::int AS open_sessions
FROM session_group FROM session_group
GROUP BY day GROUP BY day
) day_sessions ON day_sessions.day = td.day ) day_sessions ON day_sessions.day = td.day
LEFT JOIN ticket_day_billing tdb ON tdb.day = td.day LEFT JOIN ticket_day_billing tdb ON tdb.day = td.day
GROUP BY td.day, td.tracked_minutes, day_sessions.session_count, day_sessions.billed_minutes, day_sessions.non_billable_minutes, day_sessions.open_minutes GROUP BY td.day, td.tracked_minutes, day_sessions.session_count, day_sessions.billed_minutes, day_sessions.non_billable_minutes, day_sessions.open_minutes, day_sessions.billed_sessions, day_sessions.non_billable_sessions, day_sessions.open_sessions
ORDER BY td.day ASC; ORDER BY td.day ASC;
`, `,
[period.startIso, period.endIso, userId] [period.startIso, period.endIso, userId]
@@ -813,6 +825,9 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string) {
COALESCE(SUM(COALESCE(billed_minutes, 0)), 0)::int AS billed_minutes, COALESCE(SUM(COALESCE(billed_minutes, 0)), 0)::int AS billed_minutes,
COALESCE(SUM(COALESCE(non_billable_minutes, 0)), 0)::int AS non_billable_minutes, COALESCE(SUM(COALESCE(non_billable_minutes, 0)), 0)::int AS non_billable_minutes,
COALESCE(SUM(COALESCE(open_minutes, 0)), 0)::int AS open_minutes, COALESCE(SUM(COALESCE(open_minutes, 0)), 0)::int AS open_minutes,
COALESCE(SUM(billed_sessions), 0)::int AS billed_sessions,
COALESCE(SUM(non_billable_sessions), 0)::int AS non_billable_sessions,
COALESCE(SUM(open_count), 0)::int AS open_sessions,
COALESCE(SUM(crm_billed_minutes), 0)::int AS crm_billed_minutes COALESCE(SUM(crm_billed_minutes), 0)::int AS crm_billed_minutes
FROM session_group_with_crm FROM session_group_with_crm
GROUP BY organization_id, organization_name GROUP BY organization_id, organization_name
@@ -831,6 +846,9 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string) {
COALESCE(SUM(COALESCE(billed_minutes, 0)), 0)::int AS billed_minutes, COALESCE(SUM(COALESCE(billed_minutes, 0)), 0)::int AS billed_minutes,
COALESCE(SUM(COALESCE(non_billable_minutes, 0)), 0)::int AS non_billable_minutes, COALESCE(SUM(COALESCE(non_billable_minutes, 0)), 0)::int AS non_billable_minutes,
COALESCE(SUM(COALESCE(open_minutes, 0)), 0)::int AS open_minutes, COALESCE(SUM(COALESCE(open_minutes, 0)), 0)::int AS open_minutes,
COALESCE(SUM(billed_sessions), 0)::int AS billed_sessions,
COALESCE(SUM(non_billable_sessions), 0)::int AS non_billable_sessions,
COALESCE(SUM(open_count), 0)::int AS open_sessions,
COALESCE(SUM(crm_billed_minutes), 0)::int AS crm_billed_minutes COALESCE(SUM(crm_billed_minutes), 0)::int AS crm_billed_minutes
FROM session_group_with_crm FROM session_group_with_crm
GROUP BY work_type GROUP BY work_type
@@ -852,6 +870,8 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string) {
COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status = 'billed'), 0)::int AS billed_minutes, COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status = 'billed'), 0)::int AS billed_minutes,
COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status = 'non_billable'), 0)::int AS non_billable_minutes, COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status = 'non_billable'), 0)::int AS non_billable_minutes,
COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status IS NULL), 0)::int AS open_minutes, COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status IS NULL), 0)::int AS open_minutes,
COUNT(*) FILTER (WHERE billing_status = 'billed')::int AS billed_sessions,
COUNT(*) FILTER (WHERE billing_status = 'non_billable')::int AS non_billable_sessions,
COUNT(*) FILTER (WHERE billing_status IS NULL)::int AS open_sessions, COUNT(*) FILTER (WHERE billing_status IS NULL)::int AS open_sessions,
COALESCE((SELECT SUM(crm_billed_minutes)::int FROM ticket_day_billing tdb WHERE tdb.ticket_id = session_base.ticket_id), 0)::int AS crm_billed_minutes COALESCE((SELECT SUM(crm_billed_minutes)::int FROM ticket_day_billing tdb WHERE tdb.ticket_id = session_base.ticket_id), 0)::int AS crm_billed_minutes
FROM session_base FROM session_base
@@ -913,7 +933,7 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string) {
tdb.day::text AS day, tdb.day::text AS day,
tdb.tracked_minutes, tdb.tracked_minutes,
tdb.crm_billed_minutes, tdb.crm_billed_minutes,
(tdb.tracked_minutes - tdb.crm_billed_minutes)::int AS delta_minutes (tdb.crm_billed_minutes - tdb.tracked_minutes)::int AS delta_minutes
FROM ticket_day_billing tdb FROM ticket_day_billing tdb
JOIN session_base sb ON sb.ticket_id = tdb.ticket_id AND sb.day = tdb.day JOIN session_base sb ON sb.ticket_id = tdb.ticket_id AND sb.day = tdb.day
WHERE tdb.has_crm_value WHERE tdb.has_crm_value
@@ -934,9 +954,13 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string) {
billed_minutes: 0, billed_minutes: 0,
non_billable_minutes: 0, non_billable_minutes: 0,
open_minutes: 0, open_minutes: 0,
billed_sessions: 0,
non_billable_sessions: 0,
open_sessions: 0, open_sessions: 0,
recurring_minutes: 0, recurring_minutes: 0,
manual_minutes: 0, manual_minutes: 0,
recurring_sessions: 0,
manual_sessions: 0,
average_session_minutes: 0, average_session_minutes: 0,
active_days: 0, active_days: 0,
crm_billed_minutes: 0 crm_billed_minutes: 0
@@ -954,26 +978,30 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string) {
billedMinutes: totals.billed_minutes, billedMinutes: totals.billed_minutes,
nonBillableMinutes: totals.non_billable_minutes, nonBillableMinutes: totals.non_billable_minutes,
openMinutes: totals.open_minutes, openMinutes: totals.open_minutes,
billedSessions: totals.billed_sessions,
nonBillableSessions: totals.non_billable_sessions,
openSessions: totals.open_sessions, openSessions: totals.open_sessions,
recurringMinutes: totals.recurring_minutes, recurringMinutes: totals.recurring_minutes,
manualMinutes: totals.manual_minutes, manualMinutes: totals.manual_minutes,
recurringSessions: totals.recurring_sessions,
manualSessions: totals.manual_sessions,
averageSessionMinutes: totals.average_session_minutes, averageSessionMinutes: totals.average_session_minutes,
activeDays: totals.active_days, activeDays: totals.active_days,
crmBilledMinutes: totals.crm_billed_minutes, crmBilledMinutes: totals.crm_billed_minutes,
crmDeltaMinutes: totals.minutes - totals.crm_billed_minutes crmDeltaMinutes: totals.crm_billed_minutes - totals.minutes
}, },
dailySeries: dailyResult.rows, dailySeries: dailyResult.rows,
organizations: organizationResult.rows.map((row: any) => ({ organizations: organizationResult.rows.map((row: any) => ({
...row, ...row,
crm_delta_minutes: row.total_minutes - row.crm_billed_minutes crm_delta_minutes: row.crm_billed_minutes - row.total_minutes
})), })),
workTypes: workTypeResult.rows.map((row: any) => ({ workTypes: workTypeResult.rows.map((row: any) => ({
...row, ...row,
crm_delta_minutes: row.total_minutes - row.crm_billed_minutes crm_delta_minutes: row.crm_billed_minutes - row.total_minutes
})), })),
tickets: ticketResult.rows.map((row: any) => ({ tickets: ticketResult.rows.map((row: any) => ({
...row, ...row,
crm_delta_minutes: row.total_minutes - row.crm_billed_minutes crm_delta_minutes: row.crm_billed_minutes - row.total_minutes
})), })),
attention: { attention: {
openSessions: openResult.rows, openSessions: openResult.rows,
+12
View File
@@ -199,6 +199,9 @@ export type StatisticsDailyBucket = {
billed_minutes: number; billed_minutes: number;
non_billable_minutes: number; non_billable_minutes: number;
open_minutes: number; open_minutes: number;
billed_sessions: number;
non_billable_sessions: number;
open_sessions: number;
crm_billed_minutes: number; crm_billed_minutes: number;
}; };
@@ -212,6 +215,9 @@ export type StatisticsGroup = {
billed_minutes: number; billed_minutes: number;
non_billable_minutes: number; non_billable_minutes: number;
open_minutes: number; open_minutes: number;
billed_sessions: number;
non_billable_sessions: number;
open_sessions: number;
crm_billed_minutes: number; crm_billed_minutes: number;
crm_delta_minutes: number; crm_delta_minutes: number;
}; };
@@ -227,6 +233,8 @@ export type StatisticsTicket = {
billed_minutes: number; billed_minutes: number;
non_billable_minutes: number; non_billable_minutes: number;
open_minutes: number; open_minutes: number;
billed_sessions: number;
non_billable_sessions: number;
open_sessions: number; open_sessions: number;
crm_billed_minutes: number; crm_billed_minutes: number;
crm_delta_minutes: number; crm_delta_minutes: number;
@@ -264,9 +272,13 @@ export type StatisticsOverview = {
billedMinutes: number; billedMinutes: number;
nonBillableMinutes: number; nonBillableMinutes: number;
openMinutes: number; openMinutes: number;
billedSessions: number;
nonBillableSessions: number;
openSessions: number; openSessions: number;
recurringMinutes: number; recurringMinutes: number;
manualMinutes: number; manualMinutes: number;
recurringSessions: number;
manualSessions: number;
averageSessionMinutes: number; averageSessionMinutes: number;
activeDays: number; activeDays: number;
crmBilledMinutes: number; crmBilledMinutes: number;
+130 -39
View File
@@ -17,6 +17,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import type { ReactNode } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Alert } from "@/components/ui/alert"; import { Alert } from "@/components/ui/alert";
@@ -34,6 +35,13 @@ type StatisticsPageProps = {
onNavigate: (to: string) => void; 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) { function workTypeLabel(workType: WorkType | undefined) {
if (workType === "support") { if (workType === "support") {
return "Support"; return "Support";
@@ -62,6 +70,32 @@ function formatSignedMinutes(minutes: number) {
return `${minutes > 0 ? "+" : "-"}${formatMinutes(Math.abs(minutes))}`; 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) { function formatAxisMinutes(value: number) {
const minutes = Math.round(value); const minutes = Math.round(value);
@@ -122,6 +156,9 @@ function buildMonthSeries(month: string, buckets: StatisticsDailyBucket[]) {
billedMinutes: bucket?.billed_minutes ?? 0, billedMinutes: bucket?.billed_minutes ?? 0,
nonBillableMinutes: bucket?.non_billable_minutes ?? 0, nonBillableMinutes: bucket?.non_billable_minutes ?? 0,
openMinutes: bucket?.open_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 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; const y = padding.top + innerHeight - point.totalBarHeight;
return point.totalBarHeight > 0 ? ( 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; ) : 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 {points
.filter((point) => point.bucket.crmBilledMinutes > 0) .filter((point) => point.bucket.crmBilledMinutes > 0)
.map((point) => ( .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) => {points.map((point, index) =>
index % labelStep === 0 || index === points.length - 1 ? ( 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 ( return (
<Card> <Card>
<CardContent className="flex items-center gap-3 p-3"> <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>
<div className="min-w-0"> <div className="min-w-0">
<p className="text-xs font-medium text-muted-foreground">{label}</p> <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} {detail ? <p className="truncate text-xs text-muted-foreground">{detail}</p> : null}
</div> </div>
</CardContent> </CardContent>
@@ -217,12 +266,16 @@ function OrganizationRow({ organization, maxMinutes }: { organization: Statistic
<TableRow> <TableRow>
<TableCell className="max-w-[320px] whitespace-normal"> <TableCell className="max-w-[320px] whitespace-normal">
<div className="font-medium">{organization.organization_name ?? "Keine Organisation"}</div> <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>
<TableCell>{formatMinutes(organization.total_minutes)}</TableCell> <TableCell>{organization.sessions}</TableCell>
<TableCell>{formatMinutes(organization.crm_billed_minutes)}</TableCell>
<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>
<TableCell className="min-w-[150px]"> <TableCell className="min-w-[150px]">
<Progress value={percentage(organization.total_minutes, maxMinutes)} /> <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>} {ticket.open_sessions > 0 ? <Badge variant="warning">{ticket.open_sessions} offen</Badge> : <Badge variant="success">bewertet</Badge>}
</div> </div>
<p className="truncate text-sm text-muted-foreground">{ticket.organization_name ?? "Keine Organisation"}</p> <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"> <div className="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-xs">
<span>{formatMinutes(ticket.total_minutes)} getrackt</span> <span className={trackedTextClass}>Sessions {formatMinutes(ticket.total_minutes)}</span>
<span>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>{ticket.active_days} Tag(e)</span> <span>{ticket.active_days} Tag(e)</span>
<span className="text-muted-foreground">{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}`)}>
@@ -263,9 +318,10 @@ function CrmDayItem({ item, onNavigate }: { item: StatisticsCrmDay; onNavigate:
<Badge variant="outline">{formatDate(`${item.day}T00:00:00`)}</Badge> <Badge variant="outline">{formatDate(`${item.day}T00:00:00`)}</Badge>
</div> </div>
<p className="truncate text-sm text-muted-foreground">{item.organization_name}</p> <p className="truncate text-sm text-muted-foreground">{item.organization_name}</p>
<p className="text-xs text-muted-foreground"> <p className="flex flex-wrap gap-x-3 gap-y-1 text-xs">
Getrackt {formatMinutes(item.tracked_minutes)} · Teamspace {formatMinutes(item.crm_billed_minutes)} <span className={trackedTextClass}>Sessions {formatMinutes(item.tracked_minutes)}</span>
{typeof item.delta_minutes === "number" ? ` · Differenz ${formatSignedMinutes(item.delta_minutes)}` : ""} <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> </p>
</div> </div>
<Button variant="ghost" size="sm" onClick={() => onNavigate(`/analysis/day/${item.day}/tickets/${item.ticket_id}`)}> <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} ) : null}
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4"> <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
<MetricCard label="Teamspace" value={formatMinutes(totals?.crmBilledMinutes ?? 0)} detail={`Differenz ${formatSignedMinutes(totals?.crmDeltaMinutes ?? 0)}`} icon={TrendingUp} /> label="Getrackt"
<MetricCard label="Abgerechnet" value={formatMinutes(totals?.billedMinutes ?? 0)} detail={`${billableShare}% der getrackten Zeit`} icon={ListChecks} /> value={formatMinutes(totals?.minutes ?? 0)}
<MetricCard label="Offen" value={formatMinutes(totals?.openMinutes ?? 0)} detail={`${totals?.openSessions ?? 0} Session(s), ${openShare}%`} icon={CircleAlert} /> 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>
<div className="grid gap-3 lg:grid-cols-[1.6fr_1fr]"> <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 ?? []} /> <DailyEffortChart month={month} buckets={stats?.dailySeries ?? []} />
<div className="mt-3 flex flex-wrap gap-3 text-xs text-muted-foreground"> <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="inline-flex items-center gap-1.5">
<span className="size-2 rounded-sm bg-neutral-300 dark:bg-neutral-700" /> <span className={`size-2 rounded-sm ${trackedDotClass}`} />
Getrackt <span className={trackedTextClass}>Sessions</span>
</span> </span>
<span className="inline-flex items-center gap-1.5"> <span className="inline-flex items-center gap-1.5">
<span className="h-0.5 w-4 bg-foreground" /> <span className={`h-0.5 w-4 ${teamspaceDotClass}`} />
Teamspace <span className={teamspaceTextClass}>Teamspace</span>
</span> </span>
{strongestDay ? <span>Stärkster Tag: {formatDate(`${strongestDay.day}T00:00:00`)} mit {formatMinutes(strongestDay.total_minutes)}</span> : null} {strongestDay ? <span>Stärkster Tag: {formatDate(`${strongestDay.day}T00:00:00`)} mit {formatMinutes(strongestDay.total_minutes)}</span> : null}
</div> </div>
@@ -435,11 +513,13 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
<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>
<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>
<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">Fix</p> <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>
<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">Aktive Tage</p> <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"> <div className="rounded-md border bg-muted/20 p-3 text-sm">
<p className="text-muted-foreground">Top Organisation</p> <p className="text-muted-foreground">Top Organisation</p>
<p className="truncate font-semibold">{topOrganization.organization_name}</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> </div>
) : null} ) : null}
</CardContent> </CardContent>
@@ -473,9 +556,9 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Organisation</TableHead> <TableHead>Organisation</TableHead>
<TableHead>Zeit</TableHead> <TableHead>Sessions</TableHead>
<TableHead>Teamspace</TableHead> <TableHead>Zeiten</TableHead>
<TableHead>Differenz</TableHead> <TableHead>TS-Differenz</TableHead>
<TableHead>Anteil</TableHead> <TableHead>Anteil</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
@@ -494,10 +577,14 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
<p className="truncate font-medium">{organization.organization_name ?? "Keine Organisation"}</p> <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> <p className="text-xs text-muted-foreground">{organization.tickets} Ticket(s) · {organization.sessions} Session(s)</p>
</div> </div>
<Badge variant={organization.crm_delta_minutes === 0 ? "success" : "warning"}>{formatSignedMinutes(organization.crm_delta_minutes)}</Badge> <DeltaBadge minutes={organization.crm_delta_minutes} />
</div> </div>
<Progress value={percentage(organization.total_minutes, maxOrganizationMinutes)} /> <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>
))} ))}
</div> </div>
@@ -518,13 +605,17 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
<p className="font-semibold">{workTypeLabel(group.work_type)}</p> <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> <p className="text-xs text-muted-foreground">{group.tickets} Ticket(s), {group.sessions} Session(s)</p>
</div> </div>
<Badge variant="outline">{formatMinutes(group.total_minutes)}</Badge> <DeltaBadge minutes={group.crm_delta_minutes} />
</div> </div>
<Progress value={percentage(group.total_minutes, totals?.minutes ?? 0)} /> <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"> <div className="grid grid-cols-3 gap-2 text-xs text-muted-foreground">
<span>Abr. {formatMinutes(group.billed_minutes)}</span> <span>Abr. {group.billed_sessions} / {formatMinutes(group.billed_minutes)}</span>
<span>Nicht {formatMinutes(group.non_billable_minutes)}</span> <span>Nicht {group.non_billable_sessions} / {formatMinutes(group.non_billable_minutes)}</span>
<span>Offen {formatMinutes(group.open_minutes)}</span> <span>Offen {group.open_sessions} / {formatMinutes(group.open_minutes)}</span>
</div> </div>
</div> </div>
))} ))}
@@ -613,8 +704,8 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
<CardContent className="flex items-center gap-3 p-3"> <CardContent className="flex items-center gap-3 p-3">
<ArrowUpRight className="size-4 text-muted-foreground" /> <ArrowUpRight className="size-4 text-muted-foreground" />
<div> <div>
<p className="text-xs text-muted-foreground">Mehr getrackt als Teamspace</p> <p className="text-xs text-muted-foreground">Mehr Teamspace als Sessions</p>
<p className="font-semibold">{formatMinutes(Math.max(totals?.crmDeltaMinutes ?? 0, 0))}</p> <p className={`font-semibold ${teamspaceTextClass}`}>{formatMinutes(Math.max(totals?.crmDeltaMinutes ?? 0, 0))}</p>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -622,8 +713,8 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
<CardContent className="flex items-center gap-3 p-3"> <CardContent className="flex items-center gap-3 p-3">
<ArrowDownRight className="size-4 text-muted-foreground" /> <ArrowDownRight className="size-4 text-muted-foreground" />
<div> <div>
<p className="text-xs text-muted-foreground">Mehr Teamspace als getrackt</p> <p className="text-xs text-muted-foreground">Teamspace zu wenig</p>
<p className="font-semibold">{formatMinutes(Math.max(-(totals?.crmDeltaMinutes ?? 0), 0))}</p> <p className={`font-semibold ${trackedTextClass}`}>{formatMinutes(Math.max(-(totals?.crmDeltaMinutes ?? 0), 0))}</p>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>