Exclude unresolved time from statistics

This commit is contained in:
2026-08-05 20:51:59 +02:00
parent 2aec561f3c
commit cf7b33f331
4 changed files with 28 additions and 56 deletions
+7 -5
View File
@@ -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."
]
+16 -49
View File
@@ -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
<TableCell className="max-w-[320px] whitespace-normal">
<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)
{organization.tickets} Ticket(s) · {organization.billed_sessions} abgerechnet
</div>
</TableCell>
<TableCell>{organization.sessions}</TableCell>
<TableCell>{organization.billed_sessions}</TableCell>
<TableCell>
<TimeComparison trackedMinutes={organization.total_minutes} crmMinutes={organization.crm_billed_minutes} />
</TableCell>
@@ -372,7 +346,7 @@ function TicketHotspot({ ticket, month, onNavigate }: { ticket: StatisticsTicket
<span className={trackedTextClass}>Abgerechnet {formatMinutes(ticket.total_minutes)}</span>
<span className={teamspaceTextClass}>Teamspace {formatMinutes(ticket.crm_billed_minutes)}</span>
<span className={nonBillableTextClass}>Nicht {formatMinutes(ticket.non_billable_minutes)}</span>
<span className="text-muted-foreground">{ticket.sessions} Session(s)</span>
<span className="text-muted-foreground">{ticket.billed_sessions} abgerechnet</span>
<span>{ticket.active_days} Tag(e)</span>
<span className={deltaTextClass(ticket.crm_delta_minutes)}>{formatTeamspaceDelta(ticket.crm_delta_minutes)}</span>
</div>
@@ -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) {
<MetricCard
label="Abgerechnet"
value={formatMinutes(totals?.minutes ?? 0)}
detail={`${billedSessions} von ${totals?.sessions ?? 0} Session(s), ${billedSessionShare}%`}
detail={`${billedSessions} Session(s), ${totals?.tickets ?? 0} Ticket(s)`}
icon={Clock3}
valueClassName={trackedTextClass}
helpAnchor="statistik-getrackt"
@@ -523,8 +495,8 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
value={formatMinutes(nonBillableMinutes)}
detail={
<div className="space-y-0.5">
<div>{nonBillableSessions} von {totals?.sessions ?? 0} Session(s), {nonBillableSessionShare}%</div>
<div>{nonBillableTimeShare}% der erfassten Zeit</div>
<div>{nonBillableSessions} Session(s)</div>
<div>wird nicht kalkuliert</div>
</div>
}
icon={ListChecks}
@@ -537,8 +509,8 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
value={formatMinutes(openMinutes)}
detail={
<div className="space-y-0.5">
<div>{openSessions} von {totals?.sessions ?? 0} Session(s), {openSessionShare}%</div>
<div>{openTimeShare}% der Zeit</div>
<div>{openSessions} Session(s)</div>
<div>wird nicht kalkuliert</div>
</div>
}
icon={CircleAlert}
@@ -570,12 +542,7 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
<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>
<span className="font-medium">{billedSessionShare}% / {nonBillableSessionShare}% / {openSessionShare}%</span>
</div>
<div className="flex h-2 overflow-hidden rounded-full bg-muted">
<div className="bg-sky-500 dark:bg-sky-400" style={{ width: `${billedSessionShare}%` }} />
<div className="bg-rose-500 dark:bg-rose-400" style={{ width: `${nonBillableSessionShare}%` }} />
<div className="bg-amber-500 dark:bg-amber-400" style={{ width: `${openSessionShare}%` }} />
<span className="font-medium">{billedSessions} abrechenbar</span>
</div>
<div className="grid gap-2 text-xs sm:grid-cols-3 xl:grid-cols-1 2xl:grid-cols-3">
<div className={trackedTextClass}>Abgerechnet: {billedSessions} Session(s), {formatMinutes(billedMinutes)}</div>
@@ -592,7 +559,7 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
</div>
<DeltaBadge minutes={totals?.crmDeltaMinutes ?? 0} />
</div>
<TimeComparison trackedMinutes={totals?.minutes ?? 0} crmMinutes={totals?.crmBilledMinutes ?? 0} sessions={totals?.sessions ?? 0} />
<TimeComparison trackedMinutes={totals?.minutes ?? 0} crmMinutes={totals?.crmBilledMinutes ?? 0} sessions={totals?.billedSessions ?? 0} />
<div className="text-xs text-muted-foreground">Teamspace-Abdeckung: {crmCoverage}% der abgerechneten Zeit</div>
</div>
@@ -651,7 +618,7 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
<TableHeader>
<TableRow>
<TableHead>Organisation</TableHead>
<TableHead>Sessions</TableHead>
<TableHead>Abgerechnet</TableHead>
<TableHead>Zeiten</TableHead>
<TableHead>Differenz</TableHead>
<TableHead>Anteil abgerechnet</TableHead>
@@ -675,7 +642,7 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<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.billed_sessions} abgerechnet</p>
</div>
<DeltaBadge minutes={organization.crm_delta_minutes} />
</div>
@@ -745,7 +712,7 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
<div className="flex items-center justify-between gap-3">
<div>
<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.billed_sessions} abgerechnet</p>
</div>
<DeltaBadge minutes={group.crm_delta_minutes} />
</div>
@@ -788,13 +755,13 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
</CardContent>
</Card>
{stats && stats.totals.sessions === 0 ? (
{stats && stats.totals.billedSessions === 0 ? (
<Card>
<CardContent className="grid place-items-center gap-2 p-8 text-center">
<Ticket className="size-8 text-muted-foreground" />
<div>
<p className="font-medium">Keine Sessions in diesem Monat</p>
<p className="text-sm text-muted-foreground">Sobald Sessions vorhanden sind, füllt sich die Statistik automatisch.</p>
<p className="font-medium">Keine abgerechneten Sessions in diesem Monat</p>
<p className="text-sm text-muted-foreground">Offene oder nicht abrechenbare Sessions werden separat gezeigt und fließen nicht in Statistiken oder Vergleiche ein.</p>
</div>
<Button variant="secondary" size="sm" onClick={() => setMonth(currentMonth())}>
<RotateCcw className="size-4" />