Use billable time for Teamspace comparisons
This commit is contained in:
@@ -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."
|
||||
]
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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}`)}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user