Refine statistics comparisons
This commit is contained in:
+35
-7
@@ -721,6 +721,8 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string) {
|
|||||||
SUM(sb.rounded_minutes) FILTER (WHERE sb.billing_status = 'billed')::int AS billed_minutes,
|
SUM(sb.rounded_minutes) FILTER (WHERE sb.billing_status = 'billed')::int AS billed_minutes,
|
||||||
SUM(sb.rounded_minutes) FILTER (WHERE sb.billing_status = 'non_billable')::int AS non_billable_minutes,
|
SUM(sb.rounded_minutes) FILTER (WHERE sb.billing_status = 'non_billable')::int AS non_billable_minutes,
|
||||||
SUM(sb.rounded_minutes) FILTER (WHERE sb.billing_status IS NULL)::int AS open_minutes,
|
SUM(sb.rounded_minutes) FILTER (WHERE sb.billing_status IS NULL)::int AS open_minutes,
|
||||||
|
COUNT(*) FILTER (WHERE sb.billing_status = 'billed')::int AS billed_sessions,
|
||||||
|
COUNT(*) FILTER (WHERE sb.billing_status = 'non_billable')::int AS non_billable_sessions,
|
||||||
COUNT(*) FILTER (WHERE sb.billing_status IS NULL)::int AS open_count,
|
COUNT(*) FILTER (WHERE sb.billing_status IS NULL)::int AS open_count,
|
||||||
COUNT(*) FILTER (WHERE sb.recurring_billing_id IS NOT NULL)::int AS recurring_session_count,
|
COUNT(*) FILTER (WHERE sb.recurring_billing_id IS NOT NULL)::int AS recurring_session_count,
|
||||||
COUNT(*) FILTER (WHERE sb.recurring_billing_id IS NULL)::int AS manual_session_count
|
COUNT(*) FILTER (WHERE sb.recurring_billing_id IS NULL)::int AS manual_session_count
|
||||||
@@ -759,9 +761,13 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string) {
|
|||||||
COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status = 'billed'), 0)::int AS billed_minutes,
|
COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status = 'billed'), 0)::int AS billed_minutes,
|
||||||
COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status = 'non_billable'), 0)::int AS non_billable_minutes,
|
COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status = 'non_billable'), 0)::int AS non_billable_minutes,
|
||||||
COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status IS NULL), 0)::int AS open_minutes,
|
COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status IS NULL), 0)::int AS open_minutes,
|
||||||
|
COUNT(*) FILTER (WHERE billing_status = 'billed')::int AS billed_sessions,
|
||||||
|
COUNT(*) FILTER (WHERE billing_status = 'non_billable')::int AS non_billable_sessions,
|
||||||
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), 0)::int AS recurring_minutes,
|
COALESCE(SUM(rounded_minutes) FILTER (WHERE recurring_billing_id IS NOT NULL), 0)::int AS recurring_minutes,
|
||||||
COALESCE(SUM(rounded_minutes) FILTER (WHERE recurring_billing_id IS NULL), 0)::int AS manual_minutes,
|
COALESCE(SUM(rounded_minutes) FILTER (WHERE recurring_billing_id IS NULL), 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 NULL)::int AS manual_sessions,
|
||||||
COALESCE(ROUND(AVG(rounded_minutes)), 0)::int AS average_session_minutes,
|
COALESCE(ROUND(AVG(rounded_minutes)), 0)::int AS average_session_minutes,
|
||||||
COUNT(DISTINCT day)::int AS active_days,
|
COUNT(DISTINCT day)::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
|
||||||
@@ -779,6 +785,9 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string) {
|
|||||||
COALESCE(day_sessions.billed_minutes, 0)::int AS billed_minutes,
|
COALESCE(day_sessions.billed_minutes, 0)::int AS billed_minutes,
|
||||||
COALESCE(day_sessions.non_billable_minutes, 0)::int AS non_billable_minutes,
|
COALESCE(day_sessions.non_billable_minutes, 0)::int AS non_billable_minutes,
|
||||||
COALESCE(day_sessions.open_minutes, 0)::int AS open_minutes,
|
COALESCE(day_sessions.open_minutes, 0)::int AS open_minutes,
|
||||||
|
COALESCE(day_sessions.billed_sessions, 0)::int AS billed_sessions,
|
||||||
|
COALESCE(day_sessions.non_billable_sessions, 0)::int AS non_billable_sessions,
|
||||||
|
COALESCE(day_sessions.open_sessions, 0)::int AS open_sessions,
|
||||||
COALESCE(SUM(tdb.crm_billed_minutes), 0)::int AS crm_billed_minutes
|
COALESCE(SUM(tdb.crm_billed_minutes), 0)::int AS crm_billed_minutes
|
||||||
FROM (
|
FROM (
|
||||||
SELECT day, SUM(tracked_minutes)::int AS tracked_minutes
|
SELECT day, SUM(tracked_minutes)::int AS tracked_minutes
|
||||||
@@ -791,12 +800,15 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string) {
|
|||||||
SUM(session_count)::int AS session_count,
|
SUM(session_count)::int AS session_count,
|
||||||
SUM(COALESCE(billed_minutes, 0))::int AS billed_minutes,
|
SUM(COALESCE(billed_minutes, 0))::int AS billed_minutes,
|
||||||
SUM(COALESCE(non_billable_minutes, 0))::int AS non_billable_minutes,
|
SUM(COALESCE(non_billable_minutes, 0))::int AS non_billable_minutes,
|
||||||
SUM(COALESCE(open_minutes, 0))::int AS open_minutes
|
SUM(COALESCE(open_minutes, 0))::int AS open_minutes,
|
||||||
|
SUM(billed_sessions)::int AS billed_sessions,
|
||||||
|
SUM(non_billable_sessions)::int AS non_billable_sessions,
|
||||||
|
SUM(open_count)::int AS open_sessions
|
||||||
FROM session_group
|
FROM session_group
|
||||||
GROUP BY day
|
GROUP BY day
|
||||||
) day_sessions ON day_sessions.day = td.day
|
) day_sessions ON day_sessions.day = td.day
|
||||||
LEFT JOIN ticket_day_billing tdb ON tdb.day = td.day
|
LEFT JOIN ticket_day_billing tdb ON tdb.day = td.day
|
||||||
GROUP BY td.day, td.tracked_minutes, day_sessions.session_count, day_sessions.billed_minutes, day_sessions.non_billable_minutes, day_sessions.open_minutes
|
GROUP BY td.day, td.tracked_minutes, day_sessions.session_count, day_sessions.billed_minutes, day_sessions.non_billable_minutes, day_sessions.open_minutes, day_sessions.billed_sessions, day_sessions.non_billable_sessions, day_sessions.open_sessions
|
||||||
ORDER BY td.day ASC;
|
ORDER BY td.day ASC;
|
||||||
`,
|
`,
|
||||||
[period.startIso, period.endIso, userId]
|
[period.startIso, period.endIso, userId]
|
||||||
@@ -813,6 +825,9 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string) {
|
|||||||
COALESCE(SUM(COALESCE(billed_minutes, 0)), 0)::int AS billed_minutes,
|
COALESCE(SUM(COALESCE(billed_minutes, 0)), 0)::int AS billed_minutes,
|
||||||
COALESCE(SUM(COALESCE(non_billable_minutes, 0)), 0)::int AS non_billable_minutes,
|
COALESCE(SUM(COALESCE(non_billable_minutes, 0)), 0)::int AS non_billable_minutes,
|
||||||
COALESCE(SUM(COALESCE(open_minutes, 0)), 0)::int AS open_minutes,
|
COALESCE(SUM(COALESCE(open_minutes, 0)), 0)::int AS open_minutes,
|
||||||
|
COALESCE(SUM(billed_sessions), 0)::int AS billed_sessions,
|
||||||
|
COALESCE(SUM(non_billable_sessions), 0)::int AS non_billable_sessions,
|
||||||
|
COALESCE(SUM(open_count), 0)::int AS open_sessions,
|
||||||
COALESCE(SUM(crm_billed_minutes), 0)::int AS crm_billed_minutes
|
COALESCE(SUM(crm_billed_minutes), 0)::int AS crm_billed_minutes
|
||||||
FROM session_group_with_crm
|
FROM session_group_with_crm
|
||||||
GROUP BY organization_id, organization_name
|
GROUP BY organization_id, organization_name
|
||||||
@@ -831,6 +846,9 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string) {
|
|||||||
COALESCE(SUM(COALESCE(billed_minutes, 0)), 0)::int AS billed_minutes,
|
COALESCE(SUM(COALESCE(billed_minutes, 0)), 0)::int AS billed_minutes,
|
||||||
COALESCE(SUM(COALESCE(non_billable_minutes, 0)), 0)::int AS non_billable_minutes,
|
COALESCE(SUM(COALESCE(non_billable_minutes, 0)), 0)::int AS non_billable_minutes,
|
||||||
COALESCE(SUM(COALESCE(open_minutes, 0)), 0)::int AS open_minutes,
|
COALESCE(SUM(COALESCE(open_minutes, 0)), 0)::int AS open_minutes,
|
||||||
|
COALESCE(SUM(billed_sessions), 0)::int AS billed_sessions,
|
||||||
|
COALESCE(SUM(non_billable_sessions), 0)::int AS non_billable_sessions,
|
||||||
|
COALESCE(SUM(open_count), 0)::int AS open_sessions,
|
||||||
COALESCE(SUM(crm_billed_minutes), 0)::int AS crm_billed_minutes
|
COALESCE(SUM(crm_billed_minutes), 0)::int AS crm_billed_minutes
|
||||||
FROM session_group_with_crm
|
FROM session_group_with_crm
|
||||||
GROUP BY work_type
|
GROUP BY work_type
|
||||||
@@ -852,6 +870,8 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string) {
|
|||||||
COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status = 'billed'), 0)::int AS billed_minutes,
|
COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status = 'billed'), 0)::int AS billed_minutes,
|
||||||
COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status = 'non_billable'), 0)::int AS non_billable_minutes,
|
COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status = 'non_billable'), 0)::int AS non_billable_minutes,
|
||||||
COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status IS NULL), 0)::int AS open_minutes,
|
COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status IS NULL), 0)::int AS open_minutes,
|
||||||
|
COUNT(*) FILTER (WHERE billing_status = 'billed')::int AS billed_sessions,
|
||||||
|
COUNT(*) FILTER (WHERE billing_status = 'non_billable')::int AS non_billable_sessions,
|
||||||
COUNT(*) FILTER (WHERE billing_status IS NULL)::int AS open_sessions,
|
COUNT(*) FILTER (WHERE billing_status IS NULL)::int AS open_sessions,
|
||||||
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
|
||||||
@@ -913,7 +933,7 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string) {
|
|||||||
tdb.day::text AS day,
|
tdb.day::text AS day,
|
||||||
tdb.tracked_minutes,
|
tdb.tracked_minutes,
|
||||||
tdb.crm_billed_minutes,
|
tdb.crm_billed_minutes,
|
||||||
(tdb.tracked_minutes - tdb.crm_billed_minutes)::int AS delta_minutes
|
(tdb.crm_billed_minutes - tdb.tracked_minutes)::int AS delta_minutes
|
||||||
FROM ticket_day_billing tdb
|
FROM ticket_day_billing tdb
|
||||||
JOIN session_base sb ON sb.ticket_id = tdb.ticket_id AND sb.day = tdb.day
|
JOIN session_base sb ON sb.ticket_id = tdb.ticket_id AND sb.day = tdb.day
|
||||||
WHERE tdb.has_crm_value
|
WHERE tdb.has_crm_value
|
||||||
@@ -934,9 +954,13 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string) {
|
|||||||
billed_minutes: 0,
|
billed_minutes: 0,
|
||||||
non_billable_minutes: 0,
|
non_billable_minutes: 0,
|
||||||
open_minutes: 0,
|
open_minutes: 0,
|
||||||
|
billed_sessions: 0,
|
||||||
|
non_billable_sessions: 0,
|
||||||
open_sessions: 0,
|
open_sessions: 0,
|
||||||
recurring_minutes: 0,
|
recurring_minutes: 0,
|
||||||
manual_minutes: 0,
|
manual_minutes: 0,
|
||||||
|
recurring_sessions: 0,
|
||||||
|
manual_sessions: 0,
|
||||||
average_session_minutes: 0,
|
average_session_minutes: 0,
|
||||||
active_days: 0,
|
active_days: 0,
|
||||||
crm_billed_minutes: 0
|
crm_billed_minutes: 0
|
||||||
@@ -954,26 +978,30 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string) {
|
|||||||
billedMinutes: totals.billed_minutes,
|
billedMinutes: totals.billed_minutes,
|
||||||
nonBillableMinutes: totals.non_billable_minutes,
|
nonBillableMinutes: totals.non_billable_minutes,
|
||||||
openMinutes: totals.open_minutes,
|
openMinutes: totals.open_minutes,
|
||||||
|
billedSessions: totals.billed_sessions,
|
||||||
|
nonBillableSessions: totals.non_billable_sessions,
|
||||||
openSessions: totals.open_sessions,
|
openSessions: totals.open_sessions,
|
||||||
recurringMinutes: totals.recurring_minutes,
|
recurringMinutes: totals.recurring_minutes,
|
||||||
manualMinutes: totals.manual_minutes,
|
manualMinutes: totals.manual_minutes,
|
||||||
|
recurringSessions: totals.recurring_sessions,
|
||||||
|
manualSessions: totals.manual_sessions,
|
||||||
averageSessionMinutes: totals.average_session_minutes,
|
averageSessionMinutes: totals.average_session_minutes,
|
||||||
activeDays: totals.active_days,
|
activeDays: totals.active_days,
|
||||||
crmBilledMinutes: totals.crm_billed_minutes,
|
crmBilledMinutes: totals.crm_billed_minutes,
|
||||||
crmDeltaMinutes: totals.minutes - totals.crm_billed_minutes
|
crmDeltaMinutes: totals.crm_billed_minutes - totals.minutes
|
||||||
},
|
},
|
||||||
dailySeries: dailyResult.rows,
|
dailySeries: dailyResult.rows,
|
||||||
organizations: organizationResult.rows.map((row: any) => ({
|
organizations: organizationResult.rows.map((row: any) => ({
|
||||||
...row,
|
...row,
|
||||||
crm_delta_minutes: row.total_minutes - row.crm_billed_minutes
|
crm_delta_minutes: row.crm_billed_minutes - row.total_minutes
|
||||||
})),
|
})),
|
||||||
workTypes: workTypeResult.rows.map((row: any) => ({
|
workTypes: workTypeResult.rows.map((row: any) => ({
|
||||||
...row,
|
...row,
|
||||||
crm_delta_minutes: row.total_minutes - row.crm_billed_minutes
|
crm_delta_minutes: row.crm_billed_minutes - row.total_minutes
|
||||||
})),
|
})),
|
||||||
tickets: ticketResult.rows.map((row: any) => ({
|
tickets: ticketResult.rows.map((row: any) => ({
|
||||||
...row,
|
...row,
|
||||||
crm_delta_minutes: row.total_minutes - row.crm_billed_minutes
|
crm_delta_minutes: row.crm_billed_minutes - row.total_minutes
|
||||||
})),
|
})),
|
||||||
attention: {
|
attention: {
|
||||||
openSessions: openResult.rows,
|
openSessions: openResult.rows,
|
||||||
|
|||||||
@@ -199,6 +199,9 @@ export type StatisticsDailyBucket = {
|
|||||||
billed_minutes: number;
|
billed_minutes: number;
|
||||||
non_billable_minutes: number;
|
non_billable_minutes: number;
|
||||||
open_minutes: number;
|
open_minutes: number;
|
||||||
|
billed_sessions: number;
|
||||||
|
non_billable_sessions: number;
|
||||||
|
open_sessions: number;
|
||||||
crm_billed_minutes: number;
|
crm_billed_minutes: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -212,6 +215,9 @@ export type StatisticsGroup = {
|
|||||||
billed_minutes: number;
|
billed_minutes: number;
|
||||||
non_billable_minutes: number;
|
non_billable_minutes: number;
|
||||||
open_minutes: number;
|
open_minutes: number;
|
||||||
|
billed_sessions: number;
|
||||||
|
non_billable_sessions: number;
|
||||||
|
open_sessions: number;
|
||||||
crm_billed_minutes: number;
|
crm_billed_minutes: number;
|
||||||
crm_delta_minutes: number;
|
crm_delta_minutes: number;
|
||||||
};
|
};
|
||||||
@@ -227,6 +233,8 @@ export type StatisticsTicket = {
|
|||||||
billed_minutes: number;
|
billed_minutes: number;
|
||||||
non_billable_minutes: number;
|
non_billable_minutes: number;
|
||||||
open_minutes: number;
|
open_minutes: number;
|
||||||
|
billed_sessions: number;
|
||||||
|
non_billable_sessions: number;
|
||||||
open_sessions: number;
|
open_sessions: number;
|
||||||
crm_billed_minutes: number;
|
crm_billed_minutes: number;
|
||||||
crm_delta_minutes: number;
|
crm_delta_minutes: number;
|
||||||
@@ -264,9 +272,13 @@ export type StatisticsOverview = {
|
|||||||
billedMinutes: number;
|
billedMinutes: number;
|
||||||
nonBillableMinutes: number;
|
nonBillableMinutes: number;
|
||||||
openMinutes: number;
|
openMinutes: number;
|
||||||
|
billedSessions: number;
|
||||||
|
nonBillableSessions: number;
|
||||||
openSessions: number;
|
openSessions: number;
|
||||||
recurringMinutes: number;
|
recurringMinutes: number;
|
||||||
manualMinutes: number;
|
manualMinutes: number;
|
||||||
|
recurringSessions: number;
|
||||||
|
manualSessions: number;
|
||||||
averageSessionMinutes: number;
|
averageSessionMinutes: number;
|
||||||
activeDays: number;
|
activeDays: number;
|
||||||
crmBilledMinutes: number;
|
crmBilledMinutes: number;
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { Alert } from "@/components/ui/alert";
|
import { Alert } from "@/components/ui/alert";
|
||||||
@@ -34,6 +35,13 @@ type StatisticsPageProps = {
|
|||||||
onNavigate: (to: string) => void;
|
onNavigate: (to: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const trackedTextClass = "text-sky-700 dark:text-sky-300";
|
||||||
|
const teamspaceTextClass = "text-emerald-700 dark:text-emerald-300";
|
||||||
|
const trackedFillClass = "fill-sky-500/70 dark:fill-sky-400/65";
|
||||||
|
const teamspaceStrokeClass = "text-emerald-600 dark:text-emerald-300";
|
||||||
|
const trackedDotClass = "bg-sky-500 dark:bg-sky-400";
|
||||||
|
const teamspaceDotClass = "bg-emerald-500 dark:bg-emerald-400";
|
||||||
|
|
||||||
function workTypeLabel(workType: WorkType | undefined) {
|
function workTypeLabel(workType: WorkType | undefined) {
|
||||||
if (workType === "support") {
|
if (workType === "support") {
|
||||||
return "Support";
|
return "Support";
|
||||||
@@ -62,6 +70,32 @@ function formatSignedMinutes(minutes: number) {
|
|||||||
return `${minutes > 0 ? "+" : "-"}${formatMinutes(Math.abs(minutes))}`;
|
return `${minutes > 0 ? "+" : "-"}${formatMinutes(Math.abs(minutes))}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatTeamspaceDelta(minutes: number) {
|
||||||
|
if (minutes === 0) {
|
||||||
|
return "ausgeglichen";
|
||||||
|
}
|
||||||
|
|
||||||
|
return `TS ${formatSignedMinutes(minutes)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeltaBadge({ minutes }: { minutes: number }) {
|
||||||
|
return (
|
||||||
|
<Badge variant={minutes === 0 ? "success" : "warning"}>
|
||||||
|
{formatTeamspaceDelta(minutes)}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TimeComparison({ trackedMinutes, crmMinutes, sessions }: { trackedMinutes: number; crmMinutes: number; sessions?: number }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-0.5 text-sm">
|
||||||
|
<div className={`font-semibold ${trackedTextClass}`}>Sessions {formatMinutes(trackedMinutes)}</div>
|
||||||
|
<div className={`font-semibold ${teamspaceTextClass}`}>Teamspace {formatMinutes(crmMinutes)}</div>
|
||||||
|
{typeof sessions === "number" ? <div className="text-xs text-muted-foreground">{sessions} Session(s)</div> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function formatAxisMinutes(value: number) {
|
function formatAxisMinutes(value: number) {
|
||||||
const minutes = Math.round(value);
|
const minutes = Math.round(value);
|
||||||
|
|
||||||
@@ -122,6 +156,9 @@ function buildMonthSeries(month: string, buckets: StatisticsDailyBucket[]) {
|
|||||||
billedMinutes: bucket?.billed_minutes ?? 0,
|
billedMinutes: bucket?.billed_minutes ?? 0,
|
||||||
nonBillableMinutes: bucket?.non_billable_minutes ?? 0,
|
nonBillableMinutes: bucket?.non_billable_minutes ?? 0,
|
||||||
openMinutes: bucket?.open_minutes ?? 0,
|
openMinutes: bucket?.open_minutes ?? 0,
|
||||||
|
billedSessions: bucket?.billed_sessions ?? 0,
|
||||||
|
nonBillableSessions: bucket?.non_billable_sessions ?? 0,
|
||||||
|
openSessions: bucket?.open_sessions ?? 0,
|
||||||
crmBilledMinutes: bucket?.crm_billed_minutes ?? 0
|
crmBilledMinutes: bucket?.crm_billed_minutes ?? 0
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -171,14 +208,14 @@ function DailyEffortChart({ month, buckets }: { month: string; buckets: Statisti
|
|||||||
const y = padding.top + innerHeight - point.totalBarHeight;
|
const y = padding.top + innerHeight - point.totalBarHeight;
|
||||||
|
|
||||||
return point.totalBarHeight > 0 ? (
|
return point.totalBarHeight > 0 ? (
|
||||||
<rect key={point.bucket.day} x={point.x - barWidth / 2} y={y} width={barWidth} height={point.totalBarHeight} rx="4" className="fill-neutral-300 dark:fill-neutral-700" />
|
<rect key={point.bucket.day} x={point.x - barWidth / 2} y={y} width={barWidth} height={point.totalBarHeight} rx="4" className={trackedFillClass} />
|
||||||
) : null;
|
) : null;
|
||||||
})}
|
})}
|
||||||
{crmPath ? <path d={crmPath} fill="none" stroke="currentColor" strokeWidth="2.5" className="text-foreground" /> : null}
|
{crmPath ? <path d={crmPath} fill="none" stroke="currentColor" strokeWidth="2.5" className={teamspaceStrokeClass} /> : null}
|
||||||
{points
|
{points
|
||||||
.filter((point) => point.bucket.crmBilledMinutes > 0)
|
.filter((point) => point.bucket.crmBilledMinutes > 0)
|
||||||
.map((point) => (
|
.map((point) => (
|
||||||
<circle key={`${point.bucket.day}-crm`} cx={point.x} cy={point.crmY} r="3" className="fill-background stroke-foreground" strokeWidth="2" />
|
<circle key={`${point.bucket.day}-crm`} cx={point.x} cy={point.crmY} r="3" className={`fill-background ${teamspaceStrokeClass}`} stroke="currentColor" strokeWidth="2" />
|
||||||
))}
|
))}
|
||||||
{points.map((point, index) =>
|
{points.map((point, index) =>
|
||||||
index % labelStep === 0 || index === points.length - 1 ? (
|
index % labelStep === 0 || index === points.length - 1 ? (
|
||||||
@@ -195,7 +232,19 @@ function DailyEffortChart({ month, buckets }: { month: string; buckets: Statisti
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MetricCard({ label, value, detail, icon: Icon }: { label: string; value: string | number; detail?: string; icon: LucideIcon }) {
|
function MetricCard({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
detail,
|
||||||
|
icon: Icon,
|
||||||
|
valueClassName = ""
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string | number;
|
||||||
|
detail?: ReactNode;
|
||||||
|
icon: LucideIcon;
|
||||||
|
valueClassName?: string;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="flex items-center gap-3 p-3">
|
<CardContent className="flex items-center gap-3 p-3">
|
||||||
@@ -204,7 +253,7 @@ function MetricCard({ label, value, detail, icon: Icon }: { label: string; value
|
|||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-xs font-medium text-muted-foreground">{label}</p>
|
<p className="text-xs font-medium text-muted-foreground">{label}</p>
|
||||||
<p className="truncate text-xl font-semibold tracking-normal">{value}</p>
|
<p className={`truncate text-xl font-semibold tracking-normal ${valueClassName}`}>{value}</p>
|
||||||
{detail ? <p className="truncate text-xs text-muted-foreground">{detail}</p> : null}
|
{detail ? <p className="truncate text-xs text-muted-foreground">{detail}</p> : null}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -217,12 +266,16 @@ function OrganizationRow({ organization, maxMinutes }: { organization: Statistic
|
|||||||
<TableRow>
|
<TableRow>
|
||||||
<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">{organization.tickets} Ticket(s) · {organization.sessions} Session(s)</div>
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{organization.tickets} Ticket(s) · {organization.sessions} Session(s)
|
||||||
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{formatMinutes(organization.total_minutes)}</TableCell>
|
<TableCell>{organization.sessions}</TableCell>
|
||||||
<TableCell>{formatMinutes(organization.crm_billed_minutes)}</TableCell>
|
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge variant={organization.crm_delta_minutes === 0 ? "success" : "warning"}>{formatSignedMinutes(organization.crm_delta_minutes)}</Badge>
|
<TimeComparison trackedMinutes={organization.total_minutes} crmMinutes={organization.crm_billed_minutes} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<DeltaBadge minutes={organization.crm_delta_minutes} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="min-w-[150px]">
|
<TableCell className="min-w-[150px]">
|
||||||
<Progress value={percentage(organization.total_minutes, maxMinutes)} />
|
<Progress value={percentage(organization.total_minutes, maxMinutes)} />
|
||||||
@@ -240,10 +293,12 @@ function TicketHotspot({ ticket, month, onNavigate }: { ticket: StatisticsTicket
|
|||||||
{ticket.open_sessions > 0 ? <Badge variant="warning">{ticket.open_sessions} offen</Badge> : <Badge variant="success">bewertet</Badge>}
|
{ticket.open_sessions > 0 ? <Badge variant="warning">{ticket.open_sessions} offen</Badge> : <Badge variant="success">bewertet</Badge>}
|
||||||
</div>
|
</div>
|
||||||
<p className="truncate text-sm text-muted-foreground">{ticket.organization_name ?? "Keine Organisation"}</p>
|
<p className="truncate text-sm text-muted-foreground">{ticket.organization_name ?? "Keine Organisation"}</p>
|
||||||
<div className="mt-1 flex flex-wrap gap-2 text-xs text-muted-foreground">
|
<div className="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-xs">
|
||||||
<span>{formatMinutes(ticket.total_minutes)} getrackt</span>
|
<span className={trackedTextClass}>Sessions {formatMinutes(ticket.total_minutes)}</span>
|
||||||
<span>Teamspace {formatMinutes(ticket.crm_billed_minutes)}</span>
|
<span className={teamspaceTextClass}>Teamspace {formatMinutes(ticket.crm_billed_minutes)}</span>
|
||||||
|
<span className="text-muted-foreground">{ticket.sessions} Session(s)</span>
|
||||||
<span>{ticket.active_days} Tag(e)</span>
|
<span>{ticket.active_days} Tag(e)</span>
|
||||||
|
<span className="text-muted-foreground">{formatTeamspaceDelta(ticket.crm_delta_minutes)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="secondary" size="sm" onClick={() => onNavigate(`/analysis/month/${month}/tickets/${ticket.ticket_id}`)}>
|
<Button variant="secondary" size="sm" onClick={() => onNavigate(`/analysis/month/${month}/tickets/${ticket.ticket_id}`)}>
|
||||||
@@ -263,9 +318,10 @@ function CrmDayItem({ item, onNavigate }: { item: StatisticsCrmDay; onNavigate:
|
|||||||
<Badge variant="outline">{formatDate(`${item.day}T00:00:00`)}</Badge>
|
<Badge variant="outline">{formatDate(`${item.day}T00:00:00`)}</Badge>
|
||||||
</div>
|
</div>
|
||||||
<p className="truncate text-sm text-muted-foreground">{item.organization_name}</p>
|
<p className="truncate text-sm text-muted-foreground">{item.organization_name}</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="flex flex-wrap gap-x-3 gap-y-1 text-xs">
|
||||||
Getrackt {formatMinutes(item.tracked_minutes)} · Teamspace {formatMinutes(item.crm_billed_minutes)}
|
<span className={trackedTextClass}>Sessions {formatMinutes(item.tracked_minutes)}</span>
|
||||||
{typeof item.delta_minutes === "number" ? ` · Differenz ${formatSignedMinutes(item.delta_minutes)}` : ""}
|
<span className={teamspaceTextClass}>Teamspace {formatMinutes(item.crm_billed_minutes)}</span>
|
||||||
|
{typeof item.delta_minutes === "number" ? <span className="text-muted-foreground">Differenz {formatTeamspaceDelta(item.delta_minutes)}</span> : null}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="ghost" size="sm" onClick={() => onNavigate(`/analysis/day/${item.day}/tickets/${item.ticket_id}`)}>
|
<Button variant="ghost" size="sm" onClick={() => onNavigate(`/analysis/day/${item.day}/tickets/${item.ticket_id}`)}>
|
||||||
@@ -381,10 +437,32 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
<MetricCard label="Getrackt" value={formatMinutes(totals?.minutes ?? 0)} detail={`${totals?.sessions ?? 0} Session(s), ${totals?.tickets ?? 0} Ticket(s)`} icon={Clock3} />
|
<MetricCard
|
||||||
<MetricCard label="Teamspace" value={formatMinutes(totals?.crmBilledMinutes ?? 0)} detail={`Differenz ${formatSignedMinutes(totals?.crmDeltaMinutes ?? 0)}`} icon={TrendingUp} />
|
label="Getrackt"
|
||||||
<MetricCard label="Abgerechnet" value={formatMinutes(totals?.billedMinutes ?? 0)} detail={`${billableShare}% der getrackten Zeit`} icon={ListChecks} />
|
value={formatMinutes(totals?.minutes ?? 0)}
|
||||||
<MetricCard label="Offen" value={formatMinutes(totals?.openMinutes ?? 0)} detail={`${totals?.openSessions ?? 0} Session(s), ${openShare}%`} icon={CircleAlert} />
|
detail={`${totals?.sessions ?? 0} Session(s), ${totals?.tickets ?? 0} Ticket(s)`}
|
||||||
|
icon={Clock3}
|
||||||
|
valueClassName={trackedTextClass}
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
label="Teamspace"
|
||||||
|
value={formatMinutes(totals?.crmBilledMinutes ?? 0)}
|
||||||
|
detail={`Differenz ${formatTeamspaceDelta(totals?.crmDeltaMinutes ?? 0)}`}
|
||||||
|
icon={TrendingUp}
|
||||||
|
valueClassName={teamspaceTextClass}
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
label="Abgerechnet"
|
||||||
|
value={formatMinutes(totals?.billedMinutes ?? 0)}
|
||||||
|
detail={`${totals?.billedSessions ?? 0} Session(s), ${billableShare}% der Zeit`}
|
||||||
|
icon={ListChecks}
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
label="Offen"
|
||||||
|
value={formatMinutes(totals?.openMinutes ?? 0)}
|
||||||
|
detail={`${totals?.openSessions ?? 0} Session(s), ${openShare}% der Zeit`}
|
||||||
|
icon={CircleAlert}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-3 lg:grid-cols-[1.6fr_1fr]">
|
<div className="grid gap-3 lg:grid-cols-[1.6fr_1fr]">
|
||||||
@@ -400,12 +478,12 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
|
|||||||
<DailyEffortChart month={month} buckets={stats?.dailySeries ?? []} />
|
<DailyEffortChart month={month} buckets={stats?.dailySeries ?? []} />
|
||||||
<div className="mt-3 flex flex-wrap gap-3 text-xs text-muted-foreground">
|
<div className="mt-3 flex flex-wrap gap-3 text-xs text-muted-foreground">
|
||||||
<span className="inline-flex items-center gap-1.5">
|
<span className="inline-flex items-center gap-1.5">
|
||||||
<span className="size-2 rounded-sm bg-neutral-300 dark:bg-neutral-700" />
|
<span className={`size-2 rounded-sm ${trackedDotClass}`} />
|
||||||
Getrackt
|
<span className={trackedTextClass}>Sessions</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="inline-flex items-center gap-1.5">
|
<span className="inline-flex items-center gap-1.5">
|
||||||
<span className="h-0.5 w-4 bg-foreground" />
|
<span className={`h-0.5 w-4 ${teamspaceDotClass}`} />
|
||||||
Teamspace
|
<span className={teamspaceTextClass}>Teamspace</span>
|
||||||
</span>
|
</span>
|
||||||
{strongestDay ? <span>Stärkster Tag: {formatDate(`${strongestDay.day}T00:00:00`)} mit {formatMinutes(strongestDay.total_minutes)}</span> : null}
|
{strongestDay ? <span>Stärkster Tag: {formatDate(`${strongestDay.day}T00:00:00`)} mit {formatMinutes(strongestDay.total_minutes)}</span> : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -435,11 +513,13 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
|
|||||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||||
<div className="rounded-md border bg-muted/20 p-3">
|
<div className="rounded-md border bg-muted/20 p-3">
|
||||||
<p className="text-muted-foreground">Manuell</p>
|
<p className="text-muted-foreground">Manuell</p>
|
||||||
<p className="font-semibold">{formatMinutes(totals?.manualMinutes ?? 0)}</p>
|
<p className={`font-semibold ${trackedTextClass}`}>{formatMinutes(totals?.manualMinutes ?? 0)}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{totals?.manualSessions ?? 0} Session(s)</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-md border bg-muted/20 p-3">
|
<div className="rounded-md border bg-muted/20 p-3">
|
||||||
<p className="text-muted-foreground">Fix</p>
|
<p className="text-muted-foreground">Fix</p>
|
||||||
<p className="font-semibold">{formatMinutes(totals?.recurringMinutes ?? 0)}</p>
|
<p className={`font-semibold ${trackedTextClass}`}>{formatMinutes(totals?.recurringMinutes ?? 0)}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{totals?.recurringSessions ?? 0} Session(s)</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-md border bg-muted/20 p-3">
|
<div className="rounded-md border bg-muted/20 p-3">
|
||||||
<p className="text-muted-foreground">Aktive Tage</p>
|
<p className="text-muted-foreground">Aktive Tage</p>
|
||||||
@@ -454,7 +534,10 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
|
|||||||
<div className="rounded-md border bg-muted/20 p-3 text-sm">
|
<div className="rounded-md border bg-muted/20 p-3 text-sm">
|
||||||
<p className="text-muted-foreground">Top Organisation</p>
|
<p className="text-muted-foreground">Top Organisation</p>
|
||||||
<p className="truncate font-semibold">{topOrganization.organization_name}</p>
|
<p className="truncate font-semibold">{topOrganization.organization_name}</p>
|
||||||
<p className="text-xs text-muted-foreground">{formatMinutes(topOrganization.total_minutes)}</p>
|
<p className="flex flex-wrap gap-x-3 gap-y-1 text-xs">
|
||||||
|
<span className={trackedTextClass}>Sessions {formatMinutes(topOrganization.total_minutes)}</span>
|
||||||
|
<span className={teamspaceTextClass}>Teamspace {formatMinutes(topOrganization.crm_billed_minutes)}</span>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -473,9 +556,9 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
|
|||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Organisation</TableHead>
|
<TableHead>Organisation</TableHead>
|
||||||
<TableHead>Zeit</TableHead>
|
<TableHead>Sessions</TableHead>
|
||||||
<TableHead>Teamspace</TableHead>
|
<TableHead>Zeiten</TableHead>
|
||||||
<TableHead>Differenz</TableHead>
|
<TableHead>TS-Differenz</TableHead>
|
||||||
<TableHead>Anteil</TableHead>
|
<TableHead>Anteil</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
@@ -494,10 +577,14 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
|
|||||||
<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.sessions} Session(s)</p>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant={organization.crm_delta_minutes === 0 ? "success" : "warning"}>{formatSignedMinutes(organization.crm_delta_minutes)}</Badge>
|
<DeltaBadge minutes={organization.crm_delta_minutes} />
|
||||||
</div>
|
</div>
|
||||||
<Progress value={percentage(organization.total_minutes, maxOrganizationMinutes)} />
|
<Progress value={percentage(organization.total_minutes, maxOrganizationMinutes)} />
|
||||||
<p className="text-xs text-muted-foreground">{formatMinutes(organization.total_minutes)} getrackt · Teamspace {formatMinutes(organization.crm_billed_minutes)}</p>
|
<div className="flex flex-wrap gap-x-3 gap-y-1 text-xs">
|
||||||
|
<span className={trackedTextClass}>Sessions {formatMinutes(organization.total_minutes)}</span>
|
||||||
|
<span className={teamspaceTextClass}>Teamspace {formatMinutes(organization.crm_billed_minutes)}</span>
|
||||||
|
<span className="text-muted-foreground">{formatTeamspaceDelta(organization.crm_delta_minutes)}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -518,13 +605,17 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
|
|||||||
<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.sessions} Session(s)</p>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="outline">{formatMinutes(group.total_minutes)}</Badge>
|
<DeltaBadge minutes={group.crm_delta_minutes} />
|
||||||
</div>
|
</div>
|
||||||
<Progress value={percentage(group.total_minutes, totals?.minutes ?? 0)} />
|
<Progress value={percentage(group.total_minutes, totals?.minutes ?? 0)} />
|
||||||
|
<div className="flex flex-wrap gap-x-3 gap-y-1 text-xs">
|
||||||
|
<span className={trackedTextClass}>Sessions {formatMinutes(group.total_minutes)}</span>
|
||||||
|
<span className={teamspaceTextClass}>Teamspace {formatMinutes(group.crm_billed_minutes)}</span>
|
||||||
|
</div>
|
||||||
<div className="grid grid-cols-3 gap-2 text-xs text-muted-foreground">
|
<div className="grid grid-cols-3 gap-2 text-xs text-muted-foreground">
|
||||||
<span>Abr. {formatMinutes(group.billed_minutes)}</span>
|
<span>Abr. {group.billed_sessions} / {formatMinutes(group.billed_minutes)}</span>
|
||||||
<span>Nicht {formatMinutes(group.non_billable_minutes)}</span>
|
<span>Nicht {group.non_billable_sessions} / {formatMinutes(group.non_billable_minutes)}</span>
|
||||||
<span>Offen {formatMinutes(group.open_minutes)}</span>
|
<span>Offen {group.open_sessions} / {formatMinutes(group.open_minutes)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -613,8 +704,8 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
|
|||||||
<CardContent className="flex items-center gap-3 p-3">
|
<CardContent className="flex items-center gap-3 p-3">
|
||||||
<ArrowUpRight className="size-4 text-muted-foreground" />
|
<ArrowUpRight className="size-4 text-muted-foreground" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-muted-foreground">Mehr getrackt als Teamspace</p>
|
<p className="text-xs text-muted-foreground">Mehr Teamspace als Sessions</p>
|
||||||
<p className="font-semibold">{formatMinutes(Math.max(totals?.crmDeltaMinutes ?? 0, 0))}</p>
|
<p className={`font-semibold ${teamspaceTextClass}`}>{formatMinutes(Math.max(totals?.crmDeltaMinutes ?? 0, 0))}</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -622,8 +713,8 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
|
|||||||
<CardContent className="flex items-center gap-3 p-3">
|
<CardContent className="flex items-center gap-3 p-3">
|
||||||
<ArrowDownRight className="size-4 text-muted-foreground" />
|
<ArrowDownRight className="size-4 text-muted-foreground" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-muted-foreground">Mehr Teamspace als getrackt</p>
|
<p className="text-xs text-muted-foreground">Teamspace zu wenig</p>
|
||||||
<p className="font-semibold">{formatMinutes(Math.max(-(totals?.crmDeltaMinutes ?? 0), 0))}</p>
|
<p className={`font-semibold ${trackedTextClass}`}>{formatMinutes(Math.max(-(totals?.crmDeltaMinutes ?? 0), 0))}</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
Reference in New Issue
Block a user