diff --git a/backend/src/index.ts b/backend/src/index.ts
index 51949c5..9e88ce0 100644
--- a/backend/src/index.ts
+++ b/backend/src/index.ts
@@ -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 = 'non_billable')::int AS non_billable_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.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
@@ -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 = 'non_billable'), 0)::int AS non_billable_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,
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,
+ 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,
COUNT(DISTINCT day)::int AS active_days,
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.non_billable_minutes, 0)::int AS non_billable_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
FROM (
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(COALESCE(billed_minutes, 0))::int AS billed_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
GROUP BY day
) day_sessions ON day_sessions.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;
`,
[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(non_billable_minutes, 0)), 0)::int AS non_billable_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
FROM session_group_with_crm
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(non_billable_minutes, 0)), 0)::int AS non_billable_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
FROM session_group_with_crm
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 = 'non_billable'), 0)::int AS non_billable_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,
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
@@ -913,7 +933,7 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string) {
tdb.day::text AS day,
tdb.tracked_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
JOIN session_base sb ON sb.ticket_id = tdb.ticket_id AND sb.day = tdb.day
WHERE tdb.has_crm_value
@@ -934,9 +954,13 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string) {
billed_minutes: 0,
non_billable_minutes: 0,
open_minutes: 0,
+ billed_sessions: 0,
+ non_billable_sessions: 0,
open_sessions: 0,
recurring_minutes: 0,
manual_minutes: 0,
+ recurring_sessions: 0,
+ manual_sessions: 0,
average_session_minutes: 0,
active_days: 0,
crm_billed_minutes: 0
@@ -954,26 +978,30 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string) {
billedMinutes: totals.billed_minutes,
nonBillableMinutes: totals.non_billable_minutes,
openMinutes: totals.open_minutes,
+ billedSessions: totals.billed_sessions,
+ nonBillableSessions: totals.non_billable_sessions,
openSessions: totals.open_sessions,
recurringMinutes: totals.recurring_minutes,
manualMinutes: totals.manual_minutes,
+ recurringSessions: totals.recurring_sessions,
+ manualSessions: totals.manual_sessions,
averageSessionMinutes: totals.average_session_minutes,
activeDays: totals.active_days,
crmBilledMinutes: totals.crm_billed_minutes,
- crmDeltaMinutes: totals.minutes - totals.crm_billed_minutes
+ crmDeltaMinutes: totals.crm_billed_minutes - totals.minutes
},
dailySeries: dailyResult.rows,
organizations: organizationResult.rows.map((row: any) => ({
...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) => ({
...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) => ({
...row,
- crm_delta_minutes: row.total_minutes - row.crm_billed_minutes
+ crm_delta_minutes: row.crm_billed_minutes - row.total_minutes
})),
attention: {
openSessions: openResult.rows,
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index 4b02f5d..3b85cfb 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -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;
diff --git a/frontend/src/views/StatisticsPage.tsx b/frontend/src/views/StatisticsPage.tsx
index 47b4229..1e43d8b 100644
--- a/frontend/src/views/StatisticsPage.tsx
+++ b/frontend/src/views/StatisticsPage.tsx
@@ -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 (
+
{label}
-{value}
+{value}
{detail ?{detail}
: null}{ticket.organization_name ?? "Keine Organisation"}
-Mehr Teamspace als getrackt
-{formatMinutes(Math.max(-(totals?.crmDeltaMinutes ?? 0), 0))}
+Teamspace zu wenig
+{formatMinutes(Math.max(-(totals?.crmDeltaMinutes ?? 0), 0))}