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
+3 -2
View File
@@ -779,8 +779,8 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string) {
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 AND billing_status = 'billed'), 0)::int AS recurring_minutes, 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, 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 NOT NULL AND billing_status = 'billed')::int AS recurring_sessions,
COUNT(*) FILTER (WHERE recurring_billing_id IS NULL)::int AS manual_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, 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, 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 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 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
GROUP BY ticket_id, ticket_number GROUP BY ticket_id, ticket_number
HAVING SUM(rounded_minutes) FILTER (WHERE billing_status = 'billed') > 0
ORDER BY total_minutes DESC, ticket_number ASC ORDER BY total_minutes DESC, ticket_number ASC
LIMIT 12; LIMIT 12;
`, `,
+2
View File
@@ -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. `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: `Teamspace` zeigt die Summe aller Teamspace-Tageswerte im Monat. Die Differenz wird immer so berechnet:
```text ```text
+7 -5
View File
@@ -227,10 +227,12 @@ export const helpSections: HelpSection[] = [
details: [ details: [
"Die Statistikseite betrachtet immer einen Monat.", "Die Statistikseite betrachtet immer einen Monat.",
"Abgerechnet zeigt nur Sessions mit der Bewertung Abgerechnet. Nicht abrechenbare und offene Zeiten sind dort nicht enthalten.", "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.", "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 positive Differenz bedeutet: In Teamspace steht mehr Zeit als abgerechnet wurde.",
"Eine negative Differenz bedeutet: In Teamspace steht weniger 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.", "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.", "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." "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.", "Abgerechnet umfasst nur Sessions des angemeldeten Benutzers, die aktiv als Abgerechnet markiert wurden.",
"Nicht abrechenbare Sessions und offene Sessions werden aus diesem Wert herausgehalten.", "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.", "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." "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.", description: "Diese Kennzahl zeigt Arbeit, die bewusst nicht abgerechnet wird.",
quickItems: [ quickItems: [
"Nur Sessions mit Bewertung Nicht abrechenbar.", "Nur Sessions mit Bewertung Nicht abrechenbar.",
"Der Prozentwert bezieht sich auf die Sessionanzahl.", "Wird nur separat ausgewiesen.",
"Nicht abrechenbare Sessions blockieren den Monatsabschluss nicht." "Nicht abrechenbare Sessions blockieren den Monatsabschluss nicht."
], ],
details: [ details: [
"Nicht abrechenbar ist getrennt von Abgerechnet, damit interne, Kulanz- oder nicht fakturierbare Arbeit sichtbar bleibt.", "Nicht abrechenbar ist getrennt von Abgerechnet, damit interne, Kulanz- oder nicht fakturierbare Arbeit sichtbar bleibt.",
"Diese Minuten werden nicht mit Teamspace verglichen.", "Diese Minuten werden nicht mit Teamspace verglichen.",
"Die Zeit zeigt die Summe der nicht abrechenbaren Session-Minuten.", "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." "Wenn eine Bewertung zurückgenommen wird, wandert die Session wieder in Offen."
] ]
}, },
@@ -307,7 +309,7 @@ export const helpSections: HelpSection[] = [
{ {
id: "statistik-verlauf", id: "statistik-verlauf",
title: "Statistik: Monatsverlauf und Qualität", 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: [ quickItems: [
"Balken zeigen abgerechnete Sessions.", "Balken zeigen abgerechnete Sessions.",
"Die Linie zeigt Teamspace-Werte.", "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.", "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 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.", "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.", "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." "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))); 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) { function formatSignedMinutes(minutes: number) {
if (minutes === 0) { if (minutes === 0) {
return "ausgeglichen"; return "ausgeglichen";
@@ -341,10 +315,10 @@ function OrganizationRow({ organization, maxMinutes, totalMinutes }: { organizat
<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"> <div className="text-xs text-muted-foreground">
{organization.tickets} Ticket(s) · {organization.sessions} Session(s) {organization.tickets} Ticket(s) · {organization.billed_sessions} abgerechnet
</div> </div>
</TableCell> </TableCell>
<TableCell>{organization.sessions}</TableCell> <TableCell>{organization.billed_sessions}</TableCell>
<TableCell> <TableCell>
<TimeComparison trackedMinutes={organization.total_minutes} crmMinutes={organization.crm_billed_minutes} /> <TimeComparison trackedMinutes={organization.total_minutes} crmMinutes={organization.crm_billed_minutes} />
</TableCell> </TableCell>
@@ -372,7 +346,7 @@ function TicketHotspot({ ticket, month, onNavigate }: { ticket: StatisticsTicket
<span className={trackedTextClass}>Abgerechnet {formatMinutes(ticket.total_minutes)}</span> <span className={trackedTextClass}>Abgerechnet {formatMinutes(ticket.total_minutes)}</span>
<span className={teamspaceTextClass}>Teamspace {formatMinutes(ticket.crm_billed_minutes)}</span> <span className={teamspaceTextClass}>Teamspace {formatMinutes(ticket.crm_billed_minutes)}</span>
<span className={nonBillableTextClass}>Nicht {formatMinutes(ticket.non_billable_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>{ticket.active_days} Tag(e)</span>
<span className={deltaTextClass(ticket.crm_delta_minutes)}>{formatTeamspaceDelta(ticket.crm_delta_minutes)}</span> <span className={deltaTextClass(ticket.crm_delta_minutes)}>{formatTeamspaceDelta(ticket.crm_delta_minutes)}</span>
</div> </div>
@@ -452,8 +426,6 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
const nonBillableMinutes = totals?.nonBillableMinutes ?? 0; const nonBillableMinutes = totals?.nonBillableMinutes ?? 0;
const openSessions = totals?.openSessions ?? 0; const openSessions = totals?.openSessions ?? 0;
const openMinutes = totals?.openMinutes ?? 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 crmCoverage = percentage(totals?.crmBilledMinutes ?? 0, totals?.minutes ?? 0);
const maxOrganizationMinutes = Math.max(...(stats?.organizations ?? []).map((organization) => organization.total_minutes), 1); const maxOrganizationMinutes = Math.max(...(stats?.organizations ?? []).map((organization) => organization.total_minutes), 1);
const topOrganization = stats?.organizations[0] ?? null; const topOrganization = stats?.organizations[0] ?? null;
@@ -503,7 +475,7 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
<MetricCard <MetricCard
label="Abgerechnet" label="Abgerechnet"
value={formatMinutes(totals?.minutes ?? 0)} 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} icon={Clock3}
valueClassName={trackedTextClass} valueClassName={trackedTextClass}
helpAnchor="statistik-getrackt" helpAnchor="statistik-getrackt"
@@ -523,8 +495,8 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
value={formatMinutes(nonBillableMinutes)} value={formatMinutes(nonBillableMinutes)}
detail={ detail={
<div className="space-y-0.5"> <div className="space-y-0.5">
<div>{nonBillableSessions} von {totals?.sessions ?? 0} Session(s), {nonBillableSessionShare}%</div> <div>{nonBillableSessions} Session(s)</div>
<div>{nonBillableTimeShare}% der erfassten Zeit</div> <div>wird nicht kalkuliert</div>
</div> </div>
} }
icon={ListChecks} icon={ListChecks}
@@ -537,8 +509,8 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
value={formatMinutes(openMinutes)} value={formatMinutes(openMinutes)}
detail={ detail={
<div className="space-y-0.5"> <div className="space-y-0.5">
<div>{openSessions} von {totals?.sessions ?? 0} Session(s), {openSessionShare}%</div> <div>{openSessions} Session(s)</div>
<div>{openTimeShare}% der Zeit</div> <div>wird nicht kalkuliert</div>
</div> </div>
} }
icon={CircleAlert} 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="space-y-2 rounded-md border bg-muted/20 p-3">
<div className="flex items-center justify-between gap-3 text-sm"> <div className="flex items-center justify-between gap-3 text-sm">
<span className="text-muted-foreground">Bewertungsstand</span> <span className="text-muted-foreground">Bewertungsstand</span>
<span className="font-medium">{billedSessionShare}% / {nonBillableSessionShare}% / {openSessionShare}%</span> <span className="font-medium">{billedSessions} abrechenbar</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}%` }} />
</div> </div>
<div className="grid gap-2 text-xs sm:grid-cols-3 xl:grid-cols-1 2xl:grid-cols-3"> <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> <div className={trackedTextClass}>Abgerechnet: {billedSessions} Session(s), {formatMinutes(billedMinutes)}</div>
@@ -592,7 +559,7 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
</div> </div>
<DeltaBadge minutes={totals?.crmDeltaMinutes ?? 0} /> <DeltaBadge minutes={totals?.crmDeltaMinutes ?? 0} />
</div> </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 className="text-xs text-muted-foreground">Teamspace-Abdeckung: {crmCoverage}% der abgerechneten Zeit</div>
</div> </div>
@@ -651,7 +618,7 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Organisation</TableHead> <TableHead>Organisation</TableHead>
<TableHead>Sessions</TableHead> <TableHead>Abgerechnet</TableHead>
<TableHead>Zeiten</TableHead> <TableHead>Zeiten</TableHead>
<TableHead>Differenz</TableHead> <TableHead>Differenz</TableHead>
<TableHead>Anteil abgerechnet</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="flex items-start justify-between gap-2">
<div className="min-w-0"> <div className="min-w-0">
<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.billed_sessions} abgerechnet</p>
</div> </div>
<DeltaBadge minutes={organization.crm_delta_minutes} /> <DeltaBadge minutes={organization.crm_delta_minutes} />
</div> </div>
@@ -745,7 +712,7 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div> <div>
<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.billed_sessions} abgerechnet</p>
</div> </div>
<DeltaBadge minutes={group.crm_delta_minutes} /> <DeltaBadge minutes={group.crm_delta_minutes} />
</div> </div>
@@ -788,13 +755,13 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
</CardContent> </CardContent>
</Card> </Card>
{stats && stats.totals.sessions === 0 ? ( {stats && stats.totals.billedSessions === 0 ? (
<Card> <Card>
<CardContent className="grid place-items-center gap-2 p-8 text-center"> <CardContent className="grid place-items-center gap-2 p-8 text-center">
<Ticket className="size-8 text-muted-foreground" /> <Ticket className="size-8 text-muted-foreground" />
<div> <div>
<p className="font-medium">Keine Sessions in diesem Monat</p> <p className="font-medium">Keine abgerechneten 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="text-sm text-muted-foreground">Offene oder nicht abrechenbare Sessions werden separat gezeigt und fließen nicht in Statistiken oder Vergleiche ein.</p>
</div> </div>
<Button variant="secondary" size="sm" onClick={() => setMonth(currentMonth())}> <Button variant="secondary" size="sm" onClick={() => setMonth(currentMonth())}>
<RotateCcw className="size-4" /> <RotateCcw className="size-4" />