From cf7b33f331a514f62b20dc46d189388b1ef42df9 Mon Sep 17 00:00:00 2001 From: mboehmlaender Date: Wed, 5 Aug 2026 20:51:59 +0200 Subject: [PATCH] Exclude unresolved time from statistics --- backend/src/index.ts | 5 ++- docs/user-guide.md | 2 + frontend/src/help-content.ts | 12 ++--- frontend/src/views/StatisticsPage.tsx | 65 +++++++-------------------- 4 files changed, 28 insertions(+), 56 deletions(-) diff --git a/backend/src/index.ts b/backend/src/index.ts index c7c5900..8c4080d 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -779,8 +779,8 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string) { COUNT(*) FILTER (WHERE billing_status IS NULL)::int AS open_sessions, COALESCE(SUM(rounded_minutes) FILTER (WHERE recurring_billing_id IS NOT NULL AND billing_status = 'billed'), 0)::int AS recurring_minutes, COALESCE(SUM(rounded_minutes) FILTER (WHERE recurring_billing_id IS NULL AND billing_status = 'billed'), 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, + COUNT(*) FILTER (WHERE recurring_billing_id IS NOT NULL AND billing_status = 'billed')::int AS recurring_sessions, + COUNT(*) FILTER (WHERE recurring_billing_id IS NULL AND billing_status = 'billed')::int AS manual_sessions, COALESCE(ROUND(AVG(rounded_minutes) FILTER (WHERE billing_status = 'billed')), 0)::int AS average_session_minutes, COUNT(DISTINCT day) FILTER (WHERE billing_status = 'billed')::int AS active_days, COALESCE((SELECT SUM(crm_billed_minutes)::int FROM ticket_day_billing), 0)::int AS crm_billed_minutes @@ -889,6 +889,7 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string) { 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 GROUP BY ticket_id, ticket_number + HAVING SUM(rounded_minutes) FILTER (WHERE billing_status = 'billed') > 0 ORDER BY total_minutes DESC, ticket_number ASC LIMIT 12; `, diff --git a/docs/user-guide.md b/docs/user-guide.md index b61fa2f..f5ee9cc 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -332,6 +332,8 @@ Die Seite `Statistiken` betrachtet immer einen Monat. Sie dient als Ueberblick f `Abgerechnet` zeigt die Summe aller Sessions mit Bewertung `Abgerechnet`. Nicht abrechenbare und offene Sessions sind darin nicht enthalten. Dieser Wert ist die Basis fuer den Vergleich mit Teamspace. +Alle Statistik- und Vergleichswerte verwenden nur abgerechnete Zeiten. Nicht ausgewaehlte/offene und nicht abrechenbare Sessions werden nicht in Relationen, Graphen, Teamspace-Differenzen, Hotspots, Manuell/Fix-Werte oder Durchschnittswerte eingerechnet. + `Teamspace` zeigt die Summe aller Teamspace-Tageswerte im Monat. Die Differenz wird immer so berechnet: ```text diff --git a/frontend/src/help-content.ts b/frontend/src/help-content.ts index 638f5b0..a61d5eb 100644 --- a/frontend/src/help-content.ts +++ b/frontend/src/help-content.ts @@ -227,10 +227,12 @@ export const helpSections: HelpSection[] = [ details: [ "Die Statistikseite betrachtet immer einen Monat.", "Abgerechnet zeigt nur Sessions mit der Bewertung Abgerechnet. Nicht abrechenbare und offene Zeiten sind dort nicht enthalten.", + "Alle Statistik- und Vergleichswerte nutzen nur abgerechnete Zeiten.", "Teamspace zeigt, wie viel Zeit für dieselben abrechenbaren Ticket-Tage im CRM eingetragen wurde.", "Eine positive Differenz bedeutet: In Teamspace steht mehr Zeit als abgerechnet wurde.", "Eine negative Differenz bedeutet: In Teamspace steht weniger Zeit als abgerechnet wurde.", "Nicht abrechenbar und Offen werden jeweils als eigene Werte ausgewiesen.", + "Sie werden nicht in Relationen, Graphen, Teamspace-Differenzen, Hotspots, Manuell/Fix-Werte oder Durchschnittswerte eingerechnet.", "Der Monatsverlauf zeigt abgerechnete Session-Zeit als Balken und Teamspace als Linie.", "Organisationen, Typen und Ticket-Hotspots helfen dabei, Auffälligkeiten im Monat schnell zu finden." ] @@ -248,7 +250,7 @@ export const helpSections: HelpSection[] = [ "Abgerechnet umfasst nur Sessions des angemeldeten Benutzers, die aktiv als Abgerechnet markiert wurden.", "Nicht abrechenbare Sessions und offene Sessions werden aus diesem Wert herausgehalten.", "Auch automatisch erzeugte Sessions aus fixen Abrechnungen zählen hier mit, sobald sie als Abgerechnet markiert wurden.", - "Die Prozentwerte beziehen sich auf alle Sessions des Monats.", + "Es werden keine offenen oder nicht abrechenbaren Zeiten in diesen Wert eingerechnet.", "Dieser Wert ist die Ausgangsbasis für Teamspace-Differenzen: Teamspace minus abgerechnete Session-Zeit ergibt die Differenz." ] }, @@ -276,14 +278,14 @@ export const helpSections: HelpSection[] = [ description: "Diese Kennzahl zeigt Arbeit, die bewusst nicht abgerechnet wird.", quickItems: [ "Nur Sessions mit Bewertung Nicht abrechenbar.", - "Der Prozentwert bezieht sich auf die Sessionanzahl.", + "Wird nur separat ausgewiesen.", "Nicht abrechenbare Sessions blockieren den Monatsabschluss nicht." ], details: [ "Nicht abrechenbar ist getrennt von Abgerechnet, damit interne, Kulanz- oder nicht fakturierbare Arbeit sichtbar bleibt.", "Diese Minuten werden nicht mit Teamspace verglichen.", "Die Zeit zeigt die Summe der nicht abrechenbaren Session-Minuten.", - "Die Prozentwerte werden zusammen mit abgerechneten und offenen Sessions gerundet, sodass alle Zustände zusammen 100 Prozent ergeben.", + "Es werden daraus keine Anteile, Differenzen oder Vergleichswerte berechnet.", "Wenn eine Bewertung zurückgenommen wird, wandert die Session wieder in Offen." ] }, @@ -307,7 +309,7 @@ export const helpSections: HelpSection[] = [ { id: "statistik-verlauf", title: "Statistik: Monatsverlauf und Qualität", - description: "Diese Card verbindet Tagesverlauf, Teamspace-Linie, Bewertungsstand und Qualitätswerte.", + description: "Diese Card verbindet Tagesverlauf, Teamspace-Linie und reine Statuswerte.", quickItems: [ "Balken zeigen abgerechnete Sessions.", "Die Linie zeigt Teamspace-Werte.", @@ -317,7 +319,7 @@ export const helpSections: HelpSection[] = [ "Der Monatsverlauf zeigt jeden Tag des Monats. Tage ohne Sessions bleiben leer, damit die Monatsstruktur sichtbar bleibt.", "Die blauen Balken stehen für abgerechnete Session-Zeit.", "Die grüne Linie steht für Teamspace-Zeit, sofern für den Tag Werte gepflegt wurden.", - "Der Bewertungsstand trennt Abgerechnet, Nicht abrechenbar und Offen.", + "Der Bewertungsstand weist Abgerechnet, Nicht abrechenbar und Offen nur als getrennte Werte aus.", "Der Teamspace-Abgleich zeigt die Gesamtdifferenz zwischen CRM-Werten und abgerechneter Session-Zeit.", "Manuell und Fix trennen selbst erfasste abrechenbare Sessions von automatisch erzeugten abrechenbaren Sessions aus fixen Abrechnungen." ] diff --git a/frontend/src/views/StatisticsPage.tsx b/frontend/src/views/StatisticsPage.tsx index e2d3fc2..b8a17db 100644 --- a/frontend/src/views/StatisticsPage.tsx +++ b/frontend/src/views/StatisticsPage.tsx @@ -60,32 +60,6 @@ function percentage(value: number, total: number) { return Math.max(0, Math.min(100, Math.round((value / total) * 100))); } -function roundedPercentages(values: number[]) { - const total = values.reduce((sum, value) => sum + value, 0); - - if (total <= 0) { - return values.map(() => 0); - } - - const raw = values.map((value) => (value / total) * 100); - const floors = raw.map(Math.floor); - let remainder = 100 - floors.reduce((sum, value) => sum + value, 0); - const order = raw - .map((value, index) => ({ index, fraction: value - Math.floor(value) })) - .sort((left, right) => right.fraction - left.fraction); - - for (const item of order) { - if (remainder <= 0) { - break; - } - - floors[item.index] += 1; - remainder -= 1; - } - - return floors; -} - function formatSignedMinutes(minutes: number) { if (minutes === 0) { return "ausgeglichen"; @@ -341,10 +315,10 @@ function OrganizationRow({ organization, maxMinutes, totalMinutes }: { organizat
{organization.organization_name ?? "Keine Organisation"}
- {organization.tickets} Ticket(s) · {organization.sessions} Session(s) + {organization.tickets} Ticket(s) · {organization.billed_sessions} abgerechnet
- {organization.sessions} + {organization.billed_sessions} @@ -372,7 +346,7 @@ function TicketHotspot({ ticket, month, onNavigate }: { ticket: StatisticsTicket Abgerechnet {formatMinutes(ticket.total_minutes)} Teamspace {formatMinutes(ticket.crm_billed_minutes)} Nicht {formatMinutes(ticket.non_billable_minutes)} - {ticket.sessions} Session(s) + {ticket.billed_sessions} abgerechnet {ticket.active_days} Tag(e) {formatTeamspaceDelta(ticket.crm_delta_minutes)} @@ -452,8 +426,6 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) { const nonBillableMinutes = totals?.nonBillableMinutes ?? 0; const openSessions = totals?.openSessions ?? 0; const openMinutes = totals?.openMinutes ?? 0; - const [billedSessionShare, nonBillableSessionShare, openSessionShare] = roundedPercentages([billedSessions, nonBillableSessions, openSessions]); - const [billedTimeShare, nonBillableTimeShare, openTimeShare] = roundedPercentages([billedMinutes, nonBillableMinutes, openMinutes]); const crmCoverage = percentage(totals?.crmBilledMinutes ?? 0, totals?.minutes ?? 0); const maxOrganizationMinutes = Math.max(...(stats?.organizations ?? []).map((organization) => organization.total_minutes), 1); const topOrganization = stats?.organizations[0] ?? null; @@ -503,7 +475,7 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) { -
{nonBillableSessions} von {totals?.sessions ?? 0} Session(s), {nonBillableSessionShare}%
-
{nonBillableTimeShare}% der erfassten Zeit
+
{nonBillableSessions} Session(s)
+
wird nicht kalkuliert
} icon={ListChecks} @@ -537,8 +509,8 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) { value={formatMinutes(openMinutes)} detail={
-
{openSessions} von {totals?.sessions ?? 0} Session(s), {openSessionShare}%
-
{openTimeShare}% der Zeit
+
{openSessions} Session(s)
+
wird nicht kalkuliert
} icon={CircleAlert} @@ -570,12 +542,7 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
Bewertungsstand - {billedSessionShare}% / {nonBillableSessionShare}% / {openSessionShare}% -
-
-
-
-
+ {billedSessions} abrechenbar
Abgerechnet: {billedSessions} Session(s), {formatMinutes(billedMinutes)}
@@ -592,7 +559,7 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
- +
Teamspace-Abdeckung: {crmCoverage}% der abgerechneten Zeit
@@ -651,7 +618,7 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) { Organisation - Sessions + Abgerechnet Zeiten Differenz Anteil abgerechnet @@ -675,7 +642,7 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {

{organization.organization_name ?? "Keine Organisation"}

-

{organization.tickets} Ticket(s) · {organization.sessions} Session(s)

+

{organization.tickets} Ticket(s) · {organization.billed_sessions} abgerechnet

@@ -745,7 +712,7 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {

{workTypeLabel(group.work_type)}

-

{group.tickets} Ticket(s), {group.sessions} Session(s)

+

{group.tickets} Ticket(s), {group.billed_sessions} abgerechnet

@@ -788,13 +755,13 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) { - {stats && stats.totals.sessions === 0 ? ( + {stats && stats.totals.billedSessions === 0 ? (
-

Keine Sessions in diesem Monat

-

Sobald Sessions vorhanden sind, füllt sich die Statistik automatisch.

+

Keine abgerechneten Sessions in diesem Monat

+

Offene oder nicht abrechenbare Sessions werden separat gezeigt und fließen nicht in Statistiken oder Vergleiche ein.