Exclude unresolved time from statistics
This commit is contained in:
@@ -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."
|
||||
]
|
||||
|
||||
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user