Exclude unresolved time from statistics

This commit is contained in:
michael committed 2026-08-05 20:51:59 +02:00
1 parent 2aec561f3c
commit cf7b33f331
4 files changed
+28 -56

No files matched your search

+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" />