Use billable time for Teamspace comparisons

This commit is contained in:
2026-08-05 17:06:34 +02:00
parent 76bc438b4e
commit 2aec561f3c
8 changed files with 169 additions and 130 deletions
+46 -43
View File
@@ -18,7 +18,7 @@ export const helpSections: HelpSection[] = [
],
details: [
"TicketTracker ersetzt nicht das Ticketsystem. Ticketnummern dienen als Bezug zur Arbeit, die eigentliche Dokumentation bleibt im vorhandenen Ticketsystem.",
"TicketTracker ersetzt auch nicht das CRM. Die CRM- beziehungsweise Teamspace-Zeit wird als Kontrollwert gepflegt, damit getrackte Zeiten und eingetragene Abrechnung gegenübergestellt werden können.",
"TicketTracker ersetzt auch nicht das CRM. Die CRM- beziehungsweise Teamspace-Zeit wird als Kontrollwert gepflegt, damit abgerechnete Zeiten und eingetragene Abrechnung gegenübergestellt werden können.",
"Eine Session ist immer einem Benutzer zugeordnet. In den normalen Auswertungen sieht jeder Benutzer nur seine eigenen Sessions.",
"Ticketdaten wie Organisation und Art können aus bestehenden Tickets übernommen werden, auch wenn ein anderer Benutzer das Ticket schon bearbeitet hat.",
"Tickets werden nicht abgeschlossen. Sie gruppieren Sessions. Relevant für den Abschluss ist nur, ob alle Sessions eines Monats bewertet sind.",
@@ -112,8 +112,8 @@ export const helpSections: HelpSection[] = [
"Die Monatsansicht zeigt alle Tickets, die im gewählten Monat mindestens eine Session haben.",
"Die Tagesansicht zeigt dieselben Datenlogiken auf einen einzelnen Tag gefiltert. Es gibt keinen Tagesabschluss.",
"Die Pfeile neben der Zeitraum-Eingabe springen jeweils einen Monat oder einen Tag vor beziehungsweise zurück.",
"Der Gesamtaufwand-Graph zeigt den getrackten Aufwand im Zeitraum. In der Monatsansicht pro Tag, in der Tagesansicht pro Stunde.",
"Die Ticketliste kann nach Typ, Organisation oder Status gruppiert werden. Gruppenüberschriften zeigen Anzahl, Zeit und offene Bewertungen.",
"Der Graph zeigt abgerechnete Zeit im Zeitraum. In der Monatsansicht pro Tag, in der Tagesansicht pro Stunde.",
"Die Ticketliste kann nach Typ, Organisation oder Status gruppiert werden. Gruppenüberschriften zeigen Anzahl, abgerechnete Zeit und offene Bewertungen.",
"Filter und Sortierungen bleiben im Browser gespeichert und können über Zurücksetzen wieder auf Standard gestellt werden.",
"Das Copy-Icon neben der Ticketnummer kopiert die Nummer direkt in die Zwischenablage."
]
@@ -129,7 +129,8 @@ export const helpSections: HelpSection[] = [
],
details: [
"Oben steht die Ticketnummer mit Copy-Icon, damit sie schnell in andere Systeme übernommen werden kann.",
"Die Session-Einträge sind nach Kalendertagen gruppiert. Pro Tagesgruppe steht die Summe der getrackten Zeit.",
"Die Session-Einträge sind nach Kalendertagen gruppiert. Pro Tagesgruppe steht die Summe der abgerechneten Zeit.",
"Nicht abrechenbare und offene Zeiten werden neben der abgerechneten Tageszeit separat angezeigt.",
"Das Teamspace-Feld wird als Stundenwert gepflegt, zum Beispiel 1, 1,5 oder 2.25.",
"Während der Eingabe wird der Teamspace-Wert nicht sofort gespeichert. Sobald eine Änderung vorliegt, erscheint ein Speichern-Button.",
"Ein leeres Teamspace-Feld entfernt den gespeicherten Teamspace-Wert für diesen Ticket-Tag.",
@@ -217,38 +218,38 @@ export const helpSections: HelpSection[] = [
{
id: "statistiken",
title: "Statistiken",
description: "Statistiken vergleichen getrackte Arbeit, bewertete Sessions und Teamspace-Werte.",
description: "Statistiken vergleichen abgerechnete Session-Zeit, nicht abrechenbare Arbeit, offene Bewertungen und Teamspace-Werte.",
quickItems: [
"Sessions und Teamspace haben getrennte Farben.",
"Abgerechnet und Teamspace haben getrennte Farben.",
"Positive oder gleiche Differenz ist grün.",
"Negative Differenz ist rot."
],
details: [
"Die Statistikseite betrachtet immer einen Monat.",
"Getrackt zeigt die Summe aller Session-Zeiten und die Anzahl der Sessions und Tickets.",
"Teamspace zeigt, wie viel Zeit für dieselben Ticket-Tage im CRM eingetragen wurde.",
"Eine positive Differenz bedeutet: In Teamspace steht mehr Zeit als in den Sessions getrackt wurde.",
"Eine negative Differenz bedeutet: In Teamspace steht weniger Zeit als getrackt wurde.",
"Bewertet umfasst sowohl Abgerechnet als auch Nicht abrechenbar. Offen sind nur Sessions ohne Bewertung.",
"Der Monatsverlauf zeigt getrackte Session-Zeit als Balken und Teamspace als Linie.",
"Abgerechnet zeigt nur Sessions mit der Bewertung Abgerechnet. Nicht abrechenbare und offene Zeiten sind dort nicht enthalten.",
"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.",
"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."
]
},
{
id: "statistik-getrackt",
title: "Statistik: Getrackt",
description: "Diese Kennzahl zeigt die gesamte erfasste Session-Zeit im gewählten Monat.",
title: "Statistik: Abgerechnet",
description: "Diese Kennzahl zeigt die abrechenbare Session-Zeit im gewählten Monat.",
quickItems: [
"Summe aller getrackten und nachgetragenen Sessions.",
"Zeigt zusätzlich Session- und Ticketanzahl.",
"Summe aller Sessions mit Bewertung Abgerechnet.",
"Zeigt zusätzlich die abgerechnete Sessionanzahl.",
"Basiswert für den Vergleich mit Teamspace."
],
details: [
"Getrackt umfasst alle Sessions des angemeldeten Benutzers im gewählten Monat.",
"Dabei ist egal, ob die Session bereits bewertet wurde oder noch offen ist.",
"Auch automatisch erzeugte Sessions aus fixen Abrechnungen zählen hier mit.",
"Die Ticketanzahl zeigt, auf wie viele unterschiedliche Tickets sich die Sessions verteilen.",
"Dieser Wert ist die Ausgangsbasis für Teamspace-Differenzen: Teamspace minus getrackte Session-Zeit ergibt die Differenz."
"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.",
"Dieser Wert ist die Ausgangsbasis für Teamspace-Differenzen: Teamspace minus abgerechnete Session-Zeit ergibt die Differenz."
]
},
{
@@ -263,25 +264,26 @@ export const helpSections: HelpSection[] = [
details: [
"Teamspace-Werte werden pro Ticket und Tag gepflegt, nicht pro einzelner Session.",
"Die Statistik summiert alle Teamspace-Werte des Monats für den angemeldeten Benutzer.",
"Eine positive Differenz bedeutet, dass in Teamspace mehr Zeit steht als in den Sessions getrackt wurde.",
"Eine negative Differenz bedeutet, dass in Teamspace weniger Zeit steht als getrackt wurde.",
"Eine positive Differenz bedeutet, dass in Teamspace mehr Zeit steht als abgerechnet wurde.",
"Eine negative Differenz bedeutet, dass in Teamspace weniger Zeit steht als abgerechnet wurde.",
"Nicht abrechenbare und offene Sessions fließen nicht in diese Differenz ein.",
"Fehlende Teamspace-Werte können im Monatsabschluss geprüft und bei Bedarf zur Kenntnis genommen werden."
]
},
{
id: "statistik-bewertet",
title: "Statistik: Bewertet",
description: "Diese Kennzahl zeigt, wie viel der erfassten Arbeit bereits fachlich eingeordnet wurde.",
title: "Statistik: Nicht abrechenbar",
description: "Diese Kennzahl zeigt Arbeit, die bewusst nicht abgerechnet wird.",
quickItems: [
"Abgerechnet und Nicht abrechenbar zählen beide als bewertet.",
"Nur Sessions mit Bewertung Nicht abrechenbar.",
"Der Prozentwert bezieht sich auf die Sessionanzahl.",
"Bewertete Sessions blockieren den Monatsabschluss nicht."
"Nicht abrechenbare Sessions blockieren den Monatsabschluss nicht."
],
details: [
"Bewertet ist die Summe aus Abgerechnet und Nicht abrechenbar.",
"Nicht abrechenbare Sessions fallen also nicht aus der Qualität heraus, sondern gelten als bewusst entschieden.",
"Die Zeit zeigt die Summe der bewerteten Session-Minuten.",
"Die Prozentwerte werden zusammen mit den offenen Sessions gerundet, sodass bewertet und offen zusammen 100 Prozent ergeben.",
"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.",
"Wenn eine Bewertung zurückgenommen wird, wandert die Session wieder in Offen."
]
},
@@ -307,17 +309,17 @@ export const helpSections: HelpSection[] = [
title: "Statistik: Monatsverlauf und Qualität",
description: "Diese Card verbindet Tagesverlauf, Teamspace-Linie, Bewertungsstand und Qualitätswerte.",
quickItems: [
"Balken zeigen getrackte Sessions.",
"Balken zeigen abgerechnete Sessions.",
"Die Linie zeigt Teamspace-Werte.",
"Der rechte Bereich zeigt Bewertung und Teamspace-Abgleich."
],
details: [
"Der Monatsverlauf zeigt jeden Tag des Monats. Tage ohne Sessions bleiben leer, damit die Monatsstruktur sichtbar bleibt.",
"Die blauen Balken stehen für getrackte 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.",
"Der Bewertungsstand vergleicht bewertete und offene Sessions.",
"Der Teamspace-Abgleich zeigt die Gesamtdifferenz zwischen CRM-Werten und getrackter Session-Zeit.",
"Manuell und Fix trennen selbst erfasste Sessions von automatisch erzeugten Sessions aus fixen Abrechnungen."
"Der Bewertungsstand trennt Abgerechnet, Nicht abrechenbar und Offen.",
"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."
]
},
{
@@ -332,9 +334,10 @@ export const helpSections: HelpSection[] = [
details: [
"Die Organisationsstatistik fasst alle Tickets und Sessions einer Organisation im gewählten Monat zusammen.",
"Sessions zeigt die Anzahl der einzelnen Arbeitseinträge.",
"Zeiten vergleicht getrackte Session-Zeit mit Teamspace-Zeit.",
"Die Differenz wird als Teamspace minus Sessions berechnet.",
"Der Anteil am Monat zeigt, wie groß der getrackte Aufwand dieser Organisation im Verhältnis zur größten Organisation im Monat ist.",
"Zeiten vergleicht abgerechnete Session-Zeit mit Teamspace-Zeit.",
"Die Differenz wird als Teamspace minus abgerechnete Zeit berechnet.",
"Nicht abrechenbare Zeit wird separat ausgewiesen und nicht in die Differenz gerechnet.",
"Der Anteil am Monat zeigt, wie groß die abgerechnete Zeit dieser Organisation im Verhältnis zur größten Organisation im Monat ist.",
"Bei vielen Organisationen wird die Liste paginiert, damit die Seite kompakt bleibt."
]
},
@@ -350,8 +353,8 @@ export const helpSections: HelpSection[] = [
details: [
"Die Typenstatistik gruppiert Sessions nach Art.",
"Support und Consulting werden getrennt summiert.",
"Die Zeitwerte zeigen getrackte Sessions und Teamspace für den jeweiligen Typ.",
"Die Differenz folgt derselben Logik wie überall: Teamspace minus getrackte Sessions.",
"Die Zeitwerte zeigen abgerechnete Sessions und Teamspace für den jeweiligen Typ.",
"Die Differenz folgt derselben Logik wie überall: Teamspace minus abgerechnete Session-Zeit.",
"Die Zeile Abr., Nicht und Offen zeigt, wie viele Sessions dieses Typs bereits bewertet oder noch offen sind."
]
},
@@ -366,8 +369,8 @@ export const helpSections: HelpSection[] = [
],
details: [
"Ticket-Hotspots helfen, die größten Aufwandstreiber im Monat schnell zu erkennen.",
"Die Liste zeigt die wichtigsten Tickets nach getrackter Zeit.",
"Pro Ticket siehst du Organisation, Sessionanzahl, aktive Tage, getrackte Zeit, Teamspace-Zeit und Differenz.",
"Die Liste zeigt die wichtigsten Tickets nach abgerechneter Zeit.",
"Pro Ticket siehst du Organisation, Sessionanzahl, aktive abrechenbare Tage, abgerechnete Zeit, nicht abrechenbare Zeit, Teamspace-Zeit und Differenz.",
"Offene Tickets werden mit einem Warnstatus angezeigt, damit Bewertungen nachgezogen werden können.",
"Über Öffnen gelangst du direkt in die Ticketdetailansicht des Monats."
]
+4
View File
@@ -93,6 +93,8 @@ export type TicketSummary = {
work_type: WorkType | null;
session_count: number;
total_minutes: number;
non_billable_minutes: number;
open_minutes: number;
open_count: number;
ticket_open_count: number;
billed_count: number;
@@ -134,6 +136,8 @@ export type PeriodOverview = {
sessions: number;
minutes: number;
crmBilledMinutes: number;
nonBillableMinutes: number;
openMinutes: number;
openSessions: number;
};
tickets: TicketSummary[];
+12 -11
View File
@@ -384,8 +384,9 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
const metricCards: Array<{ label: string; value: string | number; detail?: string; icon: LucideIcon }> = [
{ label: "Tickets", value: totals?.tickets ?? 0, icon: Ticket },
{ label: "Sessions", value: totals?.sessions ?? 0, icon: Users },
{ label: "Zeit", value: formatMinutes(totals?.minutes ?? 0), detail: `Teamspace ${formatMinutes(totals?.crmBilledMinutes ?? 0)}`, icon: Clock3 },
{ label: "Offen", value: totals?.openSessions ?? 0, icon: UserCheck }
{ label: "Abgerechnet", value: formatMinutes(totals?.minutes ?? 0), detail: `Teamspace ${formatMinutes(totals?.crmBilledMinutes ?? 0)}`, icon: Clock3 },
{ label: "Nicht abrechenbar", value: formatMinutes(totals?.nonBillableMinutes ?? 0), icon: UserCheck },
{ label: "Offen", value: totals?.openSessions ?? 0, detail: formatMinutes(totals?.openMinutes ?? 0), icon: UserCheck }
];
const tickets = overview?.tickets ?? [];
const organizationOptions = useMemo(() => {
@@ -495,7 +496,7 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
</div>
</div>
<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-5">
{metricCards.map(({ label, value, detail, icon: Icon }) => (
<Card key={label}>
<CardContent className="flex items-center gap-3 p-3">
@@ -515,9 +516,9 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
<Card>
<CardHeader className="gap-3 p-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
<div>
<CardTitle>Gesamtaufwand</CardTitle>
<CardTitle>Abgerechneter Aufwand</CardTitle>
<CardDescription>
{periodType === "month" ? "Aufwand pro Tag im ausgewählten Monat." : "Aufwand pro Stunde am ausgewählten Tag."}
{periodType === "month" ? "Abrechenbare Zeit pro Tag im ausgewählten Monat." : "Abrechenbare Zeit pro Stunde am ausgewählten Tag."}
</CardDescription>
</div>
<div className="flex items-center gap-2">
@@ -591,7 +592,7 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
<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="size-2 rounded-sm bg-neutral-300 dark:bg-neutral-700" />
Minuten
Abgerechnet
</span>
<span className="inline-flex items-center gap-1.5">
<span className="h-0.5 w-4 bg-foreground" />
@@ -749,8 +750,8 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
<TableHead>Organisation</TableHead>
<TableHead>Art</TableHead>
<TableHead>Sessions</TableHead>
<TableHead>Zeit</TableHead>
<TableHead>Abgerechnet</TableHead>
<TableHead>Zeit abgerechnet</TableHead>
<TableHead>Sessions abgerechnet</TableHead>
<TableHead>Nicht abrechenbar</TableHead>
<TableHead>Status</TableHead>
<TableHead />
@@ -789,7 +790,7 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
<TableCell>{ticket.session_count}</TableCell>
<TableCell>{formatMinutes(ticket.total_minutes)}</TableCell>
<TableCell>{ticket.billed_count}</TableCell>
<TableCell>{ticket.non_billable_count}</TableCell>
<TableCell>{ticket.non_billable_count} / {formatMinutes(ticket.non_billable_minutes)}</TableCell>
<TableCell>
<TicketStatus ticket={ticket} />
</TableCell>
@@ -837,7 +838,7 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
</div>
<div className="grid grid-cols-3 gap-2 text-sm">
<div className="rounded-md bg-muted/50 p-2">
<p className="text-muted-foreground">Zeit</p>
<p className="text-muted-foreground">Abr.</p>
<p className="font-medium">{formatMinutes(ticket.total_minutes)}</p>
</div>
<div className="rounded-md bg-muted/50 p-2">
@@ -846,7 +847,7 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
</div>
<div className="rounded-md bg-muted/50 p-2">
<p className="text-muted-foreground">Nicht</p>
<p className="font-medium">{ticket.non_billable_count}</p>
<p className="font-medium">{formatMinutes(ticket.non_billable_minutes)}</p>
</div>
</div>
<Button className="w-full" size="sm" variant="secondary" onClick={() => onNavigate(`/analysis/${periodType}/${period}/tickets/${ticket.id}`)}>
+3 -3
View File
@@ -136,7 +136,7 @@ function CrmDayItem({
</div>
<p className="truncate text-sm text-muted-foreground">{item.organization_name}</p>
<p className="flex flex-wrap gap-x-3 gap-y-1 text-xs">
<span className={trackedTextClass}>Sessions {formatMinutes(item.tracked_minutes)}</span>
<span className={trackedTextClass}>Abgerechnet {formatMinutes(item.tracked_minutes)}</span>
<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>
@@ -331,7 +331,7 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
) : null}
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-5">
<StatusCard label="Sessions" value={stats?.totals.sessions ?? 0} detail={`${formatMinutes(stats?.totals.minutes ?? 0)} getrackt`} />
<StatusCard label="Sessions" value={stats?.totals.sessions ?? 0} detail={`${formatMinutes(stats?.totals.minutes ?? 0)} abgerechnet`} />
<StatusCard label="Teamspace" value={formatMinutes(stats?.totals.crmBilledMinutes ?? 0)} detail={`Differenz ${formatTeamspaceDelta(stats?.totals.crmDeltaMinutes ?? 0)}`} />
<StatusCard label="Offene Bewertungen" value={openSessions.length} detail={`${openTicketGroups.length} Ticket(s)`} tone={openSessions.length > 0 ? "warn" : "ok"} />
<StatusCard label="Ohne Teamspace" value={missingCrmDays.length} detail={`${acknowledgedMissingCrmDays.length} zur Kenntnis genommen`} tone={missingCrmDays.length > 0 ? "warn" : "ok"} />
@@ -409,7 +409,7 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
))}
{visibleMissingCrmDays.length === 0 ? (
<p className="rounded-md border bg-muted/20 p-3 text-sm text-muted-foreground">
{acknowledgedMissingCrmDays.length > 0 ? "Alle offenen Punkte sind zur Kenntnis genommen." : "Alle getrackten Tage haben einen Teamspace-Wert."}
{acknowledgedMissingCrmDays.length > 0 ? "Alle offenen Punkte sind zur Kenntnis genommen." : "Alle abrechenbaren Tage haben einen Teamspace-Wert."}
</p>
) : null}
</section>
+46 -37
View File
@@ -36,7 +36,7 @@ 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";
const evaluatedTextClass = "text-violet-700 dark:text-violet-300";
const nonBillableTextClass = "text-rose-700 dark:text-rose-300";
const openTextClass = "text-amber-700 dark:text-amber-300";
const organizationPageSize = 8;
@@ -112,20 +112,20 @@ function DeltaBadge({ minutes }: { minutes: number }) {
function deltaDescription(minutes: number) {
if (minutes > 0) {
return "Teamspace liegt über den Sessions";
return "Teamspace liegt über der abgerechneten Zeit";
}
if (minutes < 0) {
return "Teamspace liegt unter den Sessions";
return "Teamspace liegt unter der abgerechneten Zeit";
}
return "Teamspace und Sessions sind ausgeglichen";
return "Teamspace und abgerechnete Zeit sind ausgeglichen";
}
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 ${trackedTextClass}`}>Abgerechnet {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>
@@ -137,7 +137,7 @@ function ColorLegend() {
<div className="flex flex-wrap gap-x-4 gap-y-2 rounded-md border bg-muted/20 p-3 text-xs text-muted-foreground">
<span className="inline-flex items-center gap-1.5">
<span className={`size-2 rounded-sm ${trackedDotClass}`} />
<span className={trackedTextClass}>Sessions</span>
<span className={trackedTextClass}>Abgerechnet</span>
</span>
<span className="inline-flex items-center gap-1.5">
<span className={`h-0.5 w-4 ${teamspaceDotClass}`} />
@@ -152,8 +152,8 @@ function ColorLegend() {
<span className="text-red-700 dark:text-red-300">Differenz minus</span>
</span>
<span className="inline-flex items-center gap-1.5">
<span className="size-2 rounded-full bg-violet-500 dark:bg-violet-400" />
<span className={evaluatedTextClass}>bewertet</span>
<span className="size-2 rounded-full bg-rose-500 dark:bg-rose-400" />
<span className={nonBillableTextClass}>nicht abrechenbar</span>
</span>
<span className="inline-flex items-center gap-1.5">
<span className="size-2 rounded-full bg-amber-500 dark:bg-amber-400" />
@@ -369,8 +369,9 @@ function TicketHotspot({ ticket, month, onNavigate }: { ticket: StatisticsTicket
</div>
<p className="truncate text-sm text-muted-foreground">{ticket.organization_name ?? "Keine Organisation"}</p>
<div className="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-xs">
<span className={trackedTextClass}>Sessions {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={nonBillableTextClass}>Nicht {formatMinutes(ticket.non_billable_minutes)}</span>
<span className="text-muted-foreground">{ticket.sessions} Session(s)</span>
<span>{ticket.active_days} Tag(e)</span>
<span className={deltaTextClass(ticket.crm_delta_minutes)}>{formatTeamspaceDelta(ticket.crm_delta_minutes)}</span>
@@ -445,10 +446,14 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
}
const totals = stats?.totals;
const evaluatedSessions = (totals?.billedSessions ?? 0) + (totals?.nonBillableSessions ?? 0);
const evaluatedMinutes = (totals?.billedMinutes ?? 0) + (totals?.nonBillableMinutes ?? 0);
const [evaluatedSessionShare, openSessionShare] = roundedPercentages([evaluatedSessions, totals?.openSessions ?? 0]);
const [evaluatedTimeShare, openTimeShare] = roundedPercentages([evaluatedMinutes, totals?.openMinutes ?? 0]);
const billedSessions = totals?.billedSessions ?? 0;
const billedMinutes = totals?.billedMinutes ?? 0;
const nonBillableSessions = totals?.nonBillableSessions ?? 0;
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;
@@ -496,13 +501,13 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<MetricCard
label="Getrackt"
label="Abgerechnet"
value={formatMinutes(totals?.minutes ?? 0)}
detail={`${totals?.sessions ?? 0} Session(s), ${totals?.tickets ?? 0} Ticket(s)`}
detail={`${billedSessions} von ${totals?.sessions ?? 0} Session(s), ${billedSessionShare}%`}
icon={Clock3}
valueClassName={trackedTextClass}
helpAnchor="statistik-getrackt"
helpLabel="Hilfe zur Kennzahl Getrackt"
helpLabel="Hilfe zur Kennzahl Abgerechnet"
/>
<MetricCard
label="Teamspace"
@@ -514,25 +519,25 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
helpLabel="Hilfe zur Kennzahl Teamspace"
/>
<MetricCard
label="Bewertet"
value={formatMinutes(evaluatedMinutes)}
label="Nicht abrechenbar"
value={formatMinutes(nonBillableMinutes)}
detail={
<div className="space-y-0.5">
<div>{evaluatedSessions} von {totals?.sessions ?? 0} Session(s), {evaluatedSessionShare}%</div>
<div>{evaluatedTimeShare}% der Zeit · Abr. {totals?.billedSessions ?? 0} · Nicht {totals?.nonBillableSessions ?? 0}</div>
<div>{nonBillableSessions} von {totals?.sessions ?? 0} Session(s), {nonBillableSessionShare}%</div>
<div>{nonBillableTimeShare}% der erfassten Zeit</div>
</div>
}
icon={ListChecks}
valueClassName={evaluatedTextClass}
valueClassName={nonBillableTextClass}
helpAnchor="statistik-bewertet"
helpLabel="Hilfe zur Kennzahl Bewertet"
helpLabel="Hilfe zur Kennzahl Nicht abrechenbar"
/>
<MetricCard
label="Offen"
value={formatMinutes(totals?.openMinutes ?? 0)}
value={formatMinutes(openMinutes)}
detail={
<div className="space-y-0.5">
<div>{totals?.openSessions ?? 0} von {totals?.sessions ?? 0} Session(s), {openSessionShare}%</div>
<div>{openSessions} von {totals?.sessions ?? 0} Session(s), {openSessionShare}%</div>
<div>{openTimeShare}% der Zeit</div>
</div>
}
@@ -547,7 +552,7 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
<CardHeader className="gap-3 p-4 sm:flex-row sm:items-start sm:justify-between sm:space-y-0">
<div>
<CardTitle>Monatsverlauf und Abrechnungsqualität</CardTitle>
<CardDescription>Getrackter Aufwand pro Tag, Teamspace-Linie und Bewertungsstand in einem Überblick.</CardDescription>
<CardDescription>Abgerechnete Zeit pro Tag, Teamspace-Linie und Bewertungsstand in einem Überblick.</CardDescription>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline">{crmCoverage}% Teamspace-Abdeckung</Badge>
@@ -558,22 +563,24 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
<div className="space-y-3">
<DailyEffortChart month={month} buckets={stats?.dailySeries ?? []} />
<ColorLegend />
{strongestDay ? <p className="text-xs text-muted-foreground">Stärkster Tag: {formatDate(`${strongestDay.day}T00:00:00`)} mit {formatMinutes(strongestDay.total_minutes)}</p> : null}
{strongestDay ? <p className="text-xs text-muted-foreground">Stärkster abrechenbarer Tag: {formatDate(`${strongestDay.day}T00:00:00`)} mit {formatMinutes(strongestDay.total_minutes)}</p> : null}
</div>
<div className="space-y-4">
<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">{evaluatedSessionShare}% / {openSessionShare}%</span>
<span className="font-medium">{billedSessionShare}% / {nonBillableSessionShare}% / {openSessionShare}%</span>
</div>
<div className="flex h-2 overflow-hidden rounded-full bg-muted">
<div className="bg-violet-500 dark:bg-violet-400" style={{ width: `${evaluatedSessionShare}%` }} />
<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 className="grid gap-2 text-xs sm:grid-cols-2 xl:grid-cols-1 2xl:grid-cols-2">
<div className={evaluatedTextClass}>Bewertet: {evaluatedSessions} Session(s), {formatMinutes(evaluatedMinutes)}</div>
<div className={openTextClass}>Offen: {totals?.openSessions ?? 0} Session(s), {formatMinutes(totals?.openMinutes ?? 0)}</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>
<div className={nonBillableTextClass}>Nicht: {nonBillableSessions} Session(s), {formatMinutes(nonBillableMinutes)}</div>
<div className={openTextClass}>Offen: {openSessions} Session(s), {formatMinutes(openMinutes)}</div>
</div>
</div>
@@ -586,7 +593,7 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
<DeltaBadge minutes={totals?.crmDeltaMinutes ?? 0} />
</div>
<TimeComparison trackedMinutes={totals?.minutes ?? 0} crmMinutes={totals?.crmBilledMinutes ?? 0} sessions={totals?.sessions ?? 0} />
<div className="text-xs text-muted-foreground">Teamspace-Abdeckung: {crmCoverage}% der getrackten Session-Zeit</div>
<div className="text-xs text-muted-foreground">Teamspace-Abdeckung: {crmCoverage}% der abgerechneten Zeit</div>
</div>
<div className="grid grid-cols-2 gap-2 text-sm">
@@ -614,8 +621,9 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
<p className="text-muted-foreground">Top Organisation</p>
<p className="truncate font-semibold">{topOrganization.organization_name}</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={trackedTextClass}>Abgerechnet {formatMinutes(topOrganization.total_minutes)}</span>
<span className={teamspaceTextClass}>Teamspace {formatMinutes(topOrganization.crm_billed_minutes)}</span>
<span className={nonBillableTextClass}>Nicht {formatMinutes(topOrganization.non_billable_minutes)}</span>
</p>
</div>
) : null}
@@ -628,7 +636,7 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
<CardHeader className="gap-3 p-4 sm:flex-row sm:items-start sm:justify-between sm:space-y-0">
<div>
<CardTitle>Statistik pro Organisation</CardTitle>
<CardDescription>Kundenaufwand inklusive Teamspace-Differenz und Anteil am getrackten Monatsaufwand.</CardDescription>
<CardDescription>Kundenaufwand inklusive Teamspace-Differenz und Anteil an der abgerechneten Monatszeit.</CardDescription>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline">
@@ -646,7 +654,7 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
<TableHead>Sessions</TableHead>
<TableHead>Zeiten</TableHead>
<TableHead>Differenz</TableHead>
<TableHead>Anteil am Monat</TableHead>
<TableHead>Anteil abgerechnet</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -673,8 +681,9 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
</div>
<Progress value={percentage(organization.total_minutes, maxOrganizationMinutes)} />
<div className="flex flex-wrap gap-x-3 gap-y-1 text-xs">
<span className={trackedTextClass}>Sessions {formatMinutes(organization.total_minutes)}</span>
<span className={trackedTextClass}>Abgerechnet {formatMinutes(organization.total_minutes)}</span>
<span className={teamspaceTextClass}>Teamspace {formatMinutes(organization.crm_billed_minutes)}</span>
<span className={nonBillableTextClass}>Nicht {formatMinutes(organization.non_billable_minutes)}</span>
<span className="text-muted-foreground">{percentage(organization.total_minutes, totals?.minutes ?? 0)}% vom Monat</span>
<span className={deltaTextClass(organization.crm_delta_minutes)}>{formatTeamspaceDelta(organization.crm_delta_minutes)}</span>
</div>
@@ -742,7 +751,7 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
</div>
<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={trackedTextClass}>Abgerechnet {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">
+17 -5
View File
@@ -206,22 +206,32 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate }: T
const savedDayBillingInput = (day: string) => formatHoursInput(dayBillingMap.get(day));
const isDayBillingDirty = (day: string) => (dayBillingInputs[day] ?? "") !== savedDayBillingInput(day);
const sessionGroups = useMemo(() => {
const groups = new Map<string, { key: string; label: string; totalMinutes: number; sessions: SessionEntry[] }>();
const groups = new Map<
string,
{ key: string; label: string; totalMinutes: number; nonBillableMinutes: number; openMinutes: number; sessions: SessionEntry[] }
>();
for (const session of data?.sessions ?? []) {
const key = sessionDayKey(session.started_at);
const group = groups.get(key);
const billedMinutes = session.billing_status === "billed" ? session.rounded_minutes : 0;
const nonBillableMinutes = session.billing_status === "non_billable" ? session.rounded_minutes : 0;
const openMinutes = session.billing_status === null ? session.rounded_minutes : 0;
if (group) {
group.sessions.push(session);
group.totalMinutes += session.rounded_minutes;
group.totalMinutes += billedMinutes;
group.nonBillableMinutes += nonBillableMinutes;
group.openMinutes += openMinutes;
continue;
}
groups.set(key, {
key,
label: formatDate(session.started_at),
totalMinutes: session.rounded_minutes,
totalMinutes: billedMinutes,
nonBillableMinutes,
openMinutes,
sessions: [session]
});
}
@@ -551,7 +561,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate }: T
</div>
<div className="flex flex-wrap items-center gap-2">
<div className="flex items-center gap-2 rounded-md border bg-muted/30 px-3 py-2 text-sm">
<span className="text-muted-foreground">{periodLabel} gesamt</span>
<span className="text-muted-foreground">{periodLabel} abgerechnet</span>
<span className="font-semibold">{formatMinutes(periodTotalMinutes)}</span>
</div>
<div className="flex items-center gap-2 rounded-md border bg-muted/30 px-3 py-2 text-sm">
@@ -568,7 +578,9 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate }: T
<div className="flex flex-col gap-2 border-b bg-muted/40 px-3 py-2 sm:flex-row sm:items-center sm:justify-between">
<p className="font-medium">{group.label}</p>
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline">Tag gesamt {formatMinutes(group.totalMinutes)}</Badge>
<Badge variant="outline">Tag abgerechnet {formatMinutes(group.totalMinutes)}</Badge>
{group.nonBillableMinutes > 0 ? <Badge variant="secondary">Nicht {formatMinutes(group.nonBillableMinutes)}</Badge> : null}
{group.openMinutes > 0 ? <Badge variant="warning">Offen {formatMinutes(group.openMinutes)}</Badge> : null}
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<label htmlFor={`crm-billing-${group.key}`}>Teamspace</label>
<Input