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 ( + + {formatTeamspaceDelta(minutes)} + + ); +} + +function TimeComparison({ trackedMinutes, crmMinutes, sessions }: { trackedMinutes: number; crmMinutes: number; sessions?: number }) { + return ( +
+
Sessions {formatMinutes(trackedMinutes)}
+
Teamspace {formatMinutes(crmMinutes)}
+ {typeof sessions === "number" ?
{sessions} Session(s)
: null} +
+ ); +} + 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 ? ( - + ) : null; })} - {crmPath ? : null} + {crmPath ? : null} {points .filter((point) => point.bucket.crmBilledMinutes > 0) .map((point) => ( - + ))} {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 ( @@ -204,7 +253,7 @@ function MetricCard({ label, value, detail, icon: Icon }: { label: string; value

{label}

-

{value}

+

{value}

{detail ?

{detail}

: null}
@@ -217,12 +266,16 @@ function OrganizationRow({ organization, maxMinutes }: { organization: Statistic
{organization.organization_name ?? "Keine Organisation"}
-
{organization.tickets} Ticket(s) · {organization.sessions} Session(s)
+
+ {organization.tickets} Ticket(s) · {organization.sessions} Session(s) +
- {formatMinutes(organization.total_minutes)} - {formatMinutes(organization.crm_billed_minutes)} + {organization.sessions} - {formatSignedMinutes(organization.crm_delta_minutes)} + + + + @@ -240,10 +293,12 @@ function TicketHotspot({ ticket, month, onNavigate }: { ticket: StatisticsTicket {ticket.open_sessions > 0 ? {ticket.open_sessions} offen : bewertet}

{ticket.organization_name ?? "Keine Organisation"}

-
- {formatMinutes(ticket.total_minutes)} getrackt - Teamspace {formatMinutes(ticket.crm_billed_minutes)} +
+ Sessions {formatMinutes(ticket.total_minutes)} + Teamspace {formatMinutes(ticket.crm_billed_minutes)} + {ticket.sessions} Session(s) {ticket.active_days} Tag(e) + {formatTeamspaceDelta(ticket.crm_delta_minutes)}