Exclude unresolved time from statistics
This commit is contained in:
1 parent
2aec561f3c
commit
cf7b33f331
4 files changed
+28
-56
No files matched your search
@@ -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