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
+18 -12
View File
@@ -549,7 +549,9 @@ async function getPeriodOverview(config: PeriodConfig, period: ParsedPeriod, use
COUNT(s.id)::int AS session_count,
COUNT(*) FILTER (WHERE s.recurring_billing_id IS NOT NULL)::int AS recurring_session_count,
COUNT(*) FILTER (WHERE s.recurring_billing_id IS NULL)::int AS manual_session_count,
COALESCE(SUM(s.rounded_minutes), 0)::int AS total_minutes,
COALESCE(SUM(s.rounded_minutes) FILTER (WHERE s.billing_status = 'billed'), 0)::int AS total_minutes,
COALESCE(SUM(s.rounded_minutes) FILTER (WHERE s.billing_status = 'non_billable'), 0)::int AS non_billable_minutes,
COALESCE(SUM(s.rounded_minutes) FILTER (WHERE s.billing_status IS NULL), 0)::int AS open_minutes,
COUNT(*) FILTER (WHERE s.billing_status IS NULL)::int AS open_count,
COUNT(*) FILTER (WHERE s.billing_status = 'billed')::int AS billed_count,
COUNT(*) FILTER (WHERE s.billing_status = 'non_billable')::int AS non_billable_count,
@@ -604,8 +606,8 @@ async function getPeriodOverview(config: PeriodConfig, period: ParsedPeriod, use
`
SELECT
${bucketExpression} AS bucket_key,
COUNT(*)::int AS session_count,
COALESCE(SUM(s.rounded_minutes), 0)::int AS total_minutes
COUNT(*) FILTER (WHERE s.billing_status = 'billed')::int AS session_count,
COALESCE(SUM(s.rounded_minutes) FILTER (WHERE s.billing_status = 'billed'), 0)::int AS total_minutes
FROM sessions s
WHERE s.started_at >= $1::timestamptz
AND s.started_at < $2::timestamptz
@@ -628,6 +630,7 @@ async function getPeriodOverview(config: PeriodConfig, period: ParsedPeriod, use
WHERE s.ticket_id = tdb.ticket_id
AND s.user_id = tdb.user_id
AND (s.started_at AT TIME ZONE 'Europe/Berlin')::date = tdb.day
AND s.billing_status = 'billed'
);
`,
[userId, period.start, period.endIso]
@@ -651,6 +654,8 @@ async function getPeriodOverview(config: PeriodConfig, period: ParsedPeriod, use
sessions: totalSessions,
minutes: totalMinutes,
crmBilledMinutes: crmBillingResult.rows[0]?.crm_billed_minutes ?? 0,
nonBillableMinutes: tickets.reduce((sum: number, ticket: any) => sum + ticket.non_billable_minutes, 0),
openMinutes: tickets.reduce((sum: number, ticket: any) => sum + ticket.open_minutes, 0),
openSessions: openSessions.length
},
tickets,
@@ -690,9 +695,10 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string) {
ticket_id,
ticket_number,
day,
SUM(rounded_minutes)::int AS tracked_minutes
SUM(rounded_minutes) FILTER (WHERE billing_status = 'billed')::int AS tracked_minutes
FROM session_base
GROUP BY ticket_id, ticket_number, day
HAVING SUM(rounded_minutes) FILTER (WHERE billing_status = 'billed') > 0
),
ticket_day_billing AS (
SELECT
@@ -723,7 +729,7 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string) {
sb.ticket_number,
sb.day,
COUNT(*)::int AS session_count,
SUM(sb.rounded_minutes)::int AS total_minutes,
COALESCE(SUM(sb.rounded_minutes) FILTER (WHERE sb.billing_status = 'billed'), 0)::int AS total_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 IS NULL)::int AS open_minutes,
@@ -764,19 +770,19 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string) {
SELECT
COUNT(DISTINCT ticket_id)::int AS tickets,
COUNT(*)::int AS sessions,
COALESCE(SUM(rounded_minutes), 0)::int AS minutes,
COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status = 'billed'), 0)::int AS 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 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,
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 NOT NULL AND billing_status = 'billed'), 0)::int AS recurring_minutes,
COALESCE(SUM(rounded_minutes) FILTER (WHERE recurring_billing_id IS NULL AND billing_status = 'billed'), 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,
COUNT(DISTINCT day)::int AS active_days,
COALESCE(ROUND(AVG(rounded_minutes) FILTER (WHERE billing_status = 'billed')), 0)::int AS average_session_minutes,
COUNT(DISTINCT day) FILTER (WHERE billing_status = 'billed')::int AS active_days,
COALESCE((SELECT SUM(crm_billed_minutes)::int FROM ticket_day_billing), 0)::int AS crm_billed_minutes
FROM session_base;
`,
@@ -871,9 +877,9 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string) {
ticket_number,
MIN(organization_id)::text AS organization_id,
MIN(organization_name) AS organization_name,
COUNT(DISTINCT day)::int AS active_days,
COUNT(DISTINCT day) FILTER (WHERE billing_status = 'billed')::int AS active_days,
COUNT(*)::int AS sessions,
COALESCE(SUM(rounded_minutes), 0)::int AS total_minutes,
COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status = 'billed'), 0)::int AS total_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 IS NULL), 0)::int AS open_minutes,
+23 -19
View File
@@ -133,10 +133,11 @@ Die oberen Karten zeigen:
- Tickets: Anzahl der Tickets im Zeitraum
- Sessions: Anzahl der Sessions im Zeitraum
- Zeit: Summe der erfassten Session-Zeit und, sofern vorhanden, die dazugehoerige Teamspace-Zeit als Vergleichswert
- Abgerechnet: Summe der Sessions mit Bewertung `Abgerechnet` und, sofern vorhanden, die dazugehoerige Teamspace-Zeit als Vergleichswert
- Nicht abrechenbar: Summe der Sessions mit Bewertung `Nicht abrechenbar`
- Offen: Anzahl unbewerteter Sessions
Der Graph `Gesamtaufwand` zeigt den Aufwand im Zeitraum:
Der Graph zeigt abgerechneten Aufwand im Zeitraum:
- In der Monatsansicht pro Tag
- In der Tagesansicht pro Stunde
@@ -151,9 +152,9 @@ Pro Ticket sind sichtbar:
- Organisation
- Art
- Anzahl Sessions
- Zeit
- Abgerechnete Zeit
- Anzahl abgerechneter Sessions
- Anzahl nicht abrechenbarer Sessions
- Anzahl und Zeit nicht abrechenbarer Sessions
- Status
Neben der Ticketnummer gibt es ein Copy-Icon. Ein Klick kopiert die Ticketnummer in die Zwischenablage.
@@ -206,7 +207,9 @@ In der Ticketdetailansicht sieht man:
Die Sessions sind pro Tag gruppiert. In jeder Tagesgruppe stehen:
- Tagesdatum
- `Tag gesamt`: Summe der erfassten Session-Zeit dieses Tages
- `Tag abgerechnet`: Summe der als `Abgerechnet` bewerteten Session-Zeit dieses Tages
- `Nicht`: Summe der als `Nicht abrechenbar` bewerteten Session-Zeit dieses Tages
- `Offen`: Summe der noch unbewerteten Session-Zeit dieses Tages
- `Teamspace`: Stunden, die fuer dieses Ticket an diesem Tag wirklich im CRM eingetragen wurden
Das Teamspace-Feld wird in Stunden eingetragen, zum Beispiel:
@@ -323,34 +326,34 @@ Fix erzeugte einzelne Sessions koennen geloescht werden, zum Beispiel wegen Urla
## Statistiken
Die Seite `Statistiken` betrachtet immer einen Monat. Sie dient als Ueberblick fuer getrackte Zeiten, Teamspace-Vergleich, Bewertungsstand und Auffaelligkeiten.
Die Seite `Statistiken` betrachtet immer einen Monat. Sie dient als Ueberblick fuer abgerechnete Zeiten, nicht abrechenbare Arbeit, Teamspace-Vergleich, Bewertungsstand und Auffaelligkeiten.
### Kennzahlen
`Getrackt` zeigt die Summe aller Session-Zeiten des Monats. Dazu werden Sessionanzahl und Ticketanzahl angezeigt. Dieser Wert ist die Basis fuer den Vergleich mit Teamspace.
`Abgerechnet` zeigt die Summe aller Sessions mit Bewertung `Abgerechnet`. Nicht abrechenbare und offene Sessions sind darin nicht enthalten. Dieser Wert ist die Basis fuer den Vergleich mit Teamspace.
`Teamspace` zeigt die Summe aller Teamspace-Tageswerte im Monat. Die Differenz wird immer so berechnet:
```text
Teamspace-Zeit minus getrackte Session-Zeit
Teamspace-Zeit minus abgerechnete Session-Zeit
```
Das bedeutet:
- Positive Differenz: In Teamspace steht mehr Zeit als getrackt wurde.
- Negative Differenz: In Teamspace steht weniger Zeit als getrackt wurde.
- Positive Differenz: In Teamspace steht mehr Zeit als abgerechnet wurde.
- Negative Differenz: In Teamspace steht weniger Zeit als abgerechnet wurde.
- Ausgeglichen: Beide Werte sind gleich.
Farben:
- Sessions: blau
- Abgerechnet: blau
- Teamspace: gruen
- Positive oder ausgeglichene Differenz: gruen
- Negative Differenz: rot
- Bewertet: violett
- Nicht abrechenbar: rose/rot
- Offen: gelb/orange
`Bewertet` zeigt alle Sessions, die entweder `Abgerechnet` oder `Nicht abrechenbar` sind. Nicht abrechenbare Sessions zaehlen also mit.
`Nicht abrechenbar` zeigt Sessions, die bewusst nicht fakturiert werden. Diese Minuten werden nicht mit Teamspace verglichen.
`Offen` zeigt Sessions ohne Bewertung. Diese blockieren den Monatsabschluss.
@@ -358,11 +361,11 @@ Farben:
Diese Card kombiniert den Tagesverlauf und die Bewertungsqualitaet.
- Balken zeigen getrackte Session-Zeit.
- Balken zeigen abgerechnete Session-Zeit.
- Die Linie zeigt Teamspace-Zeit.
- Der Bewertungsstand zeigt bewertete gegen offene Sessions.
- Der Bewertungsstand trennt abgerechnete, nicht abrechenbare und offene Sessions.
- Der Teamspace-Abgleich zeigt die Gesamtdifferenz.
- Manuell und Fix trennen manuell erfasste Sessions von automatisch erzeugten Sessions.
- Manuell und Fix trennen manuell erfasste abrechenbare Sessions von automatisch erzeugten abrechenbaren Sessions.
### Statistik pro Organisation
@@ -372,9 +375,9 @@ Sichtbar sind:
- Organisation
- Sessions
- Zeiten: Sessions und Teamspace
- Zeiten: Abgerechnet, nicht abrechenbar und Teamspace
- Differenz
- Anteil am Monat
- Anteil an der abgerechneten Monatszeit
Bei vielen Organisationen wird paginiert.
@@ -386,7 +389,8 @@ Sie zeigt je Typ:
- Tickets
- Sessions
- Session-Zeit
- Abgerechnete Session-Zeit
- Nicht abrechenbare Session-Zeit
- Teamspace-Zeit
- Differenz
- Anzahl abgerechnet, nicht abrechenbar und offen
+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