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(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 NOT NULL)::int AS recurring_session_count,
COUNT(*) FILTER (WHERE s.recurring_billing_id IS NULL)::int AS manual_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 IS NULL)::int AS open_count,
COUNT(*) FILTER (WHERE s.billing_status = 'billed')::int AS billed_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, 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 SELECT
${bucketExpression} AS bucket_key, ${bucketExpression} AS bucket_key,
COUNT(*)::int AS session_count, COUNT(*) FILTER (WHERE s.billing_status = 'billed')::int AS 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
FROM sessions s FROM sessions s
WHERE s.started_at >= $1::timestamptz WHERE s.started_at >= $1::timestamptz
AND s.started_at < $2::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 WHERE s.ticket_id = tdb.ticket_id
AND s.user_id = tdb.user_id AND s.user_id = tdb.user_id
AND (s.started_at AT TIME ZONE 'Europe/Berlin')::date = tdb.day AND (s.started_at AT TIME ZONE 'Europe/Berlin')::date = tdb.day
AND s.billing_status = 'billed'
); );
`, `,
[userId, period.start, period.endIso] [userId, period.start, period.endIso]
@@ -651,6 +654,8 @@ async function getPeriodOverview(config: PeriodConfig, period: ParsedPeriod, use
sessions: totalSessions, sessions: totalSessions,
minutes: totalMinutes, minutes: totalMinutes,
crmBilledMinutes: crmBillingResult.rows[0]?.crm_billed_minutes ?? 0, 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 openSessions: openSessions.length
}, },
tickets, tickets,
@@ -690,9 +695,10 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string) {
ticket_id, ticket_id,
ticket_number, ticket_number,
day, day,
SUM(rounded_minutes)::int AS tracked_minutes SUM(rounded_minutes) FILTER (WHERE billing_status = 'billed')::int AS tracked_minutes
FROM session_base FROM session_base
GROUP BY ticket_id, ticket_number, day GROUP BY ticket_id, ticket_number, day
HAVING SUM(rounded_minutes) FILTER (WHERE billing_status = 'billed') > 0
), ),
ticket_day_billing AS ( ticket_day_billing AS (
SELECT SELECT
@@ -723,7 +729,7 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string) {
sb.ticket_number, sb.ticket_number,
sb.day, sb.day,
COUNT(*)::int AS session_count, 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 = 'billed')::int AS billed_minutes,
SUM(sb.rounded_minutes) FILTER (WHERE sb.billing_status = 'non_billable')::int AS non_billable_minutes, SUM(sb.rounded_minutes) FILTER (WHERE sb.billing_status = 'non_billable')::int AS non_billable_minutes,
SUM(sb.rounded_minutes) FILTER (WHERE sb.billing_status IS NULL)::int AS open_minutes, SUM(sb.rounded_minutes) FILTER (WHERE sb.billing_status IS NULL)::int AS open_minutes,
@@ -764,19 +770,19 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string) {
SELECT SELECT
COUNT(DISTINCT ticket_id)::int AS tickets, COUNT(DISTINCT ticket_id)::int AS tickets,
COUNT(*)::int AS sessions, 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 = 'billed'), 0)::int AS billed_minutes,
COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status = 'non_billable'), 0)::int AS non_billable_minutes, COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status = 'non_billable'), 0)::int AS non_billable_minutes,
COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status IS NULL), 0)::int AS open_minutes, COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status IS NULL), 0)::int AS open_minutes,
COUNT(*) FILTER (WHERE billing_status = 'billed')::int AS billed_sessions, COUNT(*) FILTER (WHERE billing_status = 'billed')::int AS billed_sessions,
COUNT(*) FILTER (WHERE billing_status = 'non_billable')::int AS non_billable_sessions, COUNT(*) FILTER (WHERE billing_status = 'non_billable')::int AS non_billable_sessions,
COUNT(*) FILTER (WHERE billing_status IS NULL)::int AS open_sessions, COUNT(*) FILTER (WHERE billing_status IS NULL)::int AS open_sessions,
COALESCE(SUM(rounded_minutes) FILTER (WHERE recurring_billing_id IS NOT NULL), 0)::int AS recurring_minutes, COALESCE(SUM(rounded_minutes) FILTER (WHERE recurring_billing_id IS NOT NULL AND billing_status = 'billed'), 0)::int AS recurring_minutes,
COALESCE(SUM(rounded_minutes) FILTER (WHERE recurring_billing_id IS NULL), 0)::int AS manual_minutes, COALESCE(SUM(rounded_minutes) FILTER (WHERE recurring_billing_id IS NULL 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 NOT NULL)::int AS recurring_sessions,
COUNT(*) FILTER (WHERE recurring_billing_id IS NULL)::int AS manual_sessions, COUNT(*) FILTER (WHERE recurring_billing_id IS NULL)::int AS manual_sessions,
COALESCE(ROUND(AVG(rounded_minutes)), 0)::int AS average_session_minutes, COALESCE(ROUND(AVG(rounded_minutes) FILTER (WHERE billing_status = 'billed')), 0)::int AS average_session_minutes,
COUNT(DISTINCT day)::int AS active_days, 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 COALESCE((SELECT SUM(crm_billed_minutes)::int FROM ticket_day_billing), 0)::int AS crm_billed_minutes
FROM session_base; FROM session_base;
`, `,
@@ -871,9 +877,9 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string) {
ticket_number, ticket_number,
MIN(organization_id)::text AS organization_id, MIN(organization_id)::text AS organization_id,
MIN(organization_name) AS organization_name, 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, 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 = 'billed'), 0)::int AS billed_minutes,
COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status = 'non_billable'), 0)::int AS non_billable_minutes, COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status = 'non_billable'), 0)::int AS non_billable_minutes,
COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status IS NULL), 0)::int AS open_minutes, COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status IS NULL), 0)::int AS open_minutes,
+23 -19
View File
@@ -133,10 +133,11 @@ Die oberen Karten zeigen:
- Tickets: Anzahl der Tickets im Zeitraum - Tickets: Anzahl der Tickets im Zeitraum
- Sessions: Anzahl der Sessions 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 - 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 Monatsansicht pro Tag
- In der Tagesansicht pro Stunde - In der Tagesansicht pro Stunde
@@ -151,9 +152,9 @@ Pro Ticket sind sichtbar:
- Organisation - Organisation
- Art - Art
- Anzahl Sessions - Anzahl Sessions
- Zeit - Abgerechnete Zeit
- Anzahl abgerechneter Sessions - Anzahl abgerechneter Sessions
- Anzahl nicht abrechenbarer Sessions - Anzahl und Zeit nicht abrechenbarer Sessions
- Status - Status
Neben der Ticketnummer gibt es ein Copy-Icon. Ein Klick kopiert die Ticketnummer in die Zwischenablage. 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: Die Sessions sind pro Tag gruppiert. In jeder Tagesgruppe stehen:
- Tagesdatum - 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 - `Teamspace`: Stunden, die fuer dieses Ticket an diesem Tag wirklich im CRM eingetragen wurden
Das Teamspace-Feld wird in Stunden eingetragen, zum Beispiel: 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 ## 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 ### 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: `Teamspace` zeigt die Summe aller Teamspace-Tageswerte im Monat. Die Differenz wird immer so berechnet:
```text ```text
Teamspace-Zeit minus getrackte Session-Zeit Teamspace-Zeit minus abgerechnete Session-Zeit
``` ```
Das bedeutet: Das bedeutet:
- Positive Differenz: In Teamspace steht mehr Zeit als getrackt wurde. - Positive Differenz: In Teamspace steht mehr Zeit als abgerechnet wurde.
- Negative Differenz: In Teamspace steht weniger Zeit als getrackt wurde. - Negative Differenz: In Teamspace steht weniger Zeit als abgerechnet wurde.
- Ausgeglichen: Beide Werte sind gleich. - Ausgeglichen: Beide Werte sind gleich.
Farben: Farben:
- Sessions: blau - Abgerechnet: blau
- Teamspace: gruen - Teamspace: gruen
- Positive oder ausgeglichene Differenz: gruen - Positive oder ausgeglichene Differenz: gruen
- Negative Differenz: rot - Negative Differenz: rot
- Bewertet: violett - Nicht abrechenbar: rose/rot
- Offen: gelb/orange - 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. `Offen` zeigt Sessions ohne Bewertung. Diese blockieren den Monatsabschluss.
@@ -358,11 +361,11 @@ Farben:
Diese Card kombiniert den Tagesverlauf und die Bewertungsqualitaet. Diese Card kombiniert den Tagesverlauf und die Bewertungsqualitaet.
- Balken zeigen getrackte Session-Zeit. - Balken zeigen abgerechnete Session-Zeit.
- Die Linie zeigt Teamspace-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. - 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 ### Statistik pro Organisation
@@ -372,9 +375,9 @@ Sichtbar sind:
- Organisation - Organisation
- Sessions - Sessions
- Zeiten: Sessions und Teamspace - Zeiten: Abgerechnet, nicht abrechenbar und Teamspace
- Differenz - Differenz
- Anteil am Monat - Anteil an der abgerechneten Monatszeit
Bei vielen Organisationen wird paginiert. Bei vielen Organisationen wird paginiert.
@@ -386,7 +389,8 @@ Sie zeigt je Typ:
- Tickets - Tickets
- Sessions - Sessions
- Session-Zeit - Abgerechnete Session-Zeit
- Nicht abrechenbare Session-Zeit
- Teamspace-Zeit - Teamspace-Zeit
- Differenz - Differenz
- Anzahl abgerechnet, nicht abrechenbar und offen - Anzahl abgerechnet, nicht abrechenbar und offen
+46 -43
View File
@@ -18,7 +18,7 @@ export const helpSections: HelpSection[] = [
], ],
details: [ details: [
"TicketTracker ersetzt nicht das Ticketsystem. Ticketnummern dienen als Bezug zur Arbeit, die eigentliche Dokumentation bleibt im vorhandenen Ticketsystem.", "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.", "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.", "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.", "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 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 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.", "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.", "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, Zeit und offene Bewertungen.", "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.", "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." "Das Copy-Icon neben der Ticketnummer kopiert die Nummer direkt in die Zwischenablage."
] ]
@@ -129,7 +129,8 @@ export const helpSections: HelpSection[] = [
], ],
details: [ details: [
"Oben steht die Ticketnummer mit Copy-Icon, damit sie schnell in andere Systeme übernommen werden kann.", "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.", "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.", "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.", "Ein leeres Teamspace-Feld entfernt den gespeicherten Teamspace-Wert für diesen Ticket-Tag.",
@@ -217,38 +218,38 @@ export const helpSections: HelpSection[] = [
{ {
id: "statistiken", id: "statistiken",
title: "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: [ quickItems: [
"Sessions und Teamspace haben getrennte Farben.", "Abgerechnet und Teamspace haben getrennte Farben.",
"Positive oder gleiche Differenz ist grün.", "Positive oder gleiche Differenz ist grün.",
"Negative Differenz ist rot." "Negative Differenz ist rot."
], ],
details: [ details: [
"Die Statistikseite betrachtet immer einen Monat.", "Die Statistikseite betrachtet immer einen Monat.",
"Getrackt zeigt die Summe aller Session-Zeiten und die Anzahl der Sessions und Tickets.", "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 Ticket-Tage im CRM eingetragen wurde.", "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 in den Sessions getrackt wurde.", "Eine positive Differenz bedeutet: In Teamspace steht mehr Zeit als abgerechnet wurde.",
"Eine negative Differenz bedeutet: In Teamspace steht weniger Zeit als getrackt wurde.", "Eine negative Differenz bedeutet: In Teamspace steht weniger Zeit als abgerechnet wurde.",
"Bewertet umfasst sowohl Abgerechnet als auch Nicht abrechenbar. Offen sind nur Sessions ohne Bewertung.", "Nicht abrechenbar und Offen werden jeweils als eigene Werte ausgewiesen.",
"Der Monatsverlauf zeigt getrackte Session-Zeit als Balken und Teamspace als Linie.", "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." "Organisationen, Typen und Ticket-Hotspots helfen dabei, Auffälligkeiten im Monat schnell zu finden."
] ]
}, },
{ {
id: "statistik-getrackt", id: "statistik-getrackt",
title: "Statistik: Getrackt", title: "Statistik: Abgerechnet",
description: "Diese Kennzahl zeigt die gesamte erfasste Session-Zeit im gewählten Monat.", description: "Diese Kennzahl zeigt die abrechenbare Session-Zeit im gewählten Monat.",
quickItems: [ quickItems: [
"Summe aller getrackten und nachgetragenen Sessions.", "Summe aller Sessions mit Bewertung Abgerechnet.",
"Zeigt zusätzlich Session- und Ticketanzahl.", "Zeigt zusätzlich die abgerechnete Sessionanzahl.",
"Basiswert für den Vergleich mit Teamspace." "Basiswert für den Vergleich mit Teamspace."
], ],
details: [ details: [
"Getrackt umfasst alle Sessions des angemeldeten Benutzers im gewählten Monat.", "Abgerechnet umfasst nur Sessions des angemeldeten Benutzers, die aktiv als Abgerechnet markiert wurden.",
"Dabei ist egal, ob die Session bereits bewertet wurde oder noch offen ist.", "Nicht abrechenbare Sessions und offene Sessions werden aus diesem Wert herausgehalten.",
"Auch automatisch erzeugte Sessions aus fixen Abrechnungen zählen hier mit.", "Auch automatisch erzeugte Sessions aus fixen Abrechnungen zählen hier mit, sobald sie als Abgerechnet markiert wurden.",
"Die Ticketanzahl zeigt, auf wie viele unterschiedliche Tickets sich die Sessions verteilen.", "Die Prozentwerte beziehen sich auf alle Sessions des Monats.",
"Dieser Wert ist die Ausgangsbasis für Teamspace-Differenzen: Teamspace minus getrackte Session-Zeit ergibt die Differenz." "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: [ details: [
"Teamspace-Werte werden pro Ticket und Tag gepflegt, nicht pro einzelner Session.", "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.", "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 positive Differenz bedeutet, dass in Teamspace mehr Zeit steht als abgerechnet wurde.",
"Eine negative Differenz bedeutet, dass in Teamspace weniger Zeit steht als getrackt 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." "Fehlende Teamspace-Werte können im Monatsabschluss geprüft und bei Bedarf zur Kenntnis genommen werden."
] ]
}, },
{ {
id: "statistik-bewertet", id: "statistik-bewertet",
title: "Statistik: Bewertet", title: "Statistik: Nicht abrechenbar",
description: "Diese Kennzahl zeigt, wie viel der erfassten Arbeit bereits fachlich eingeordnet wurde.", description: "Diese Kennzahl zeigt Arbeit, die bewusst nicht abgerechnet wird.",
quickItems: [ quickItems: [
"Abgerechnet und Nicht abrechenbar zählen beide als bewertet.", "Nur Sessions mit Bewertung Nicht abrechenbar.",
"Der Prozentwert bezieht sich auf die Sessionanzahl.", "Der Prozentwert bezieht sich auf die Sessionanzahl.",
"Bewertete Sessions blockieren den Monatsabschluss nicht." "Nicht abrechenbare Sessions blockieren den Monatsabschluss nicht."
], ],
details: [ details: [
"Bewertet ist die Summe aus Abgerechnet und Nicht abrechenbar.", "Nicht abrechenbar ist getrennt von Abgerechnet, damit interne, Kulanz- oder nicht fakturierbare Arbeit sichtbar bleibt.",
"Nicht abrechenbare Sessions fallen also nicht aus der Qualität heraus, sondern gelten als bewusst entschieden.", "Diese Minuten werden nicht mit Teamspace verglichen.",
"Die Zeit zeigt die Summe der bewerteten Session-Minuten.", "Die Zeit zeigt die Summe der nicht abrechenbaren Session-Minuten.",
"Die Prozentwerte werden zusammen mit den offenen Sessions gerundet, sodass bewertet und offen zusammen 100 Prozent ergeben.", "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." "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", title: "Statistik: Monatsverlauf und Qualität",
description: "Diese Card verbindet Tagesverlauf, Teamspace-Linie, Bewertungsstand und Qualitätswerte.", description: "Diese Card verbindet Tagesverlauf, Teamspace-Linie, Bewertungsstand und Qualitätswerte.",
quickItems: [ quickItems: [
"Balken zeigen getrackte Sessions.", "Balken zeigen abgerechnete Sessions.",
"Die Linie zeigt Teamspace-Werte.", "Die Linie zeigt Teamspace-Werte.",
"Der rechte Bereich zeigt Bewertung und Teamspace-Abgleich." "Der rechte Bereich zeigt Bewertung und Teamspace-Abgleich."
], ],
details: [ details: [
"Der Monatsverlauf zeigt jeden Tag des Monats. Tage ohne Sessions bleiben leer, damit die Monatsstruktur sichtbar bleibt.", "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.", "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 Bewertungsstand trennt Abgerechnet, Nicht abrechenbar und Offen.",
"Der Teamspace-Abgleich zeigt die Gesamtdifferenz zwischen CRM-Werten und getrackter Session-Zeit.", "Der Teamspace-Abgleich zeigt die Gesamtdifferenz zwischen CRM-Werten und abgerechneter Session-Zeit.",
"Manuell und Fix trennen selbst erfasste Sessions von automatisch erzeugten Sessions aus fixen Abrechnungen." "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: [ details: [
"Die Organisationsstatistik fasst alle Tickets und Sessions einer Organisation im gewählten Monat zusammen.", "Die Organisationsstatistik fasst alle Tickets und Sessions einer Organisation im gewählten Monat zusammen.",
"Sessions zeigt die Anzahl der einzelnen Arbeitseinträge.", "Sessions zeigt die Anzahl der einzelnen Arbeitseinträge.",
"Zeiten vergleicht getrackte Session-Zeit mit Teamspace-Zeit.", "Zeiten vergleicht abgerechnete Session-Zeit mit Teamspace-Zeit.",
"Die Differenz wird als Teamspace minus Sessions berechnet.", "Die Differenz wird als Teamspace minus abgerechnete Zeit berechnet.",
"Der Anteil am Monat zeigt, wie groß der getrackte Aufwand dieser Organisation im Verhältnis zur größten Organisation im Monat ist.", "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." "Bei vielen Organisationen wird die Liste paginiert, damit die Seite kompakt bleibt."
] ]
}, },
@@ -350,8 +353,8 @@ export const helpSections: HelpSection[] = [
details: [ details: [
"Die Typenstatistik gruppiert Sessions nach Art.", "Die Typenstatistik gruppiert Sessions nach Art.",
"Support und Consulting werden getrennt summiert.", "Support und Consulting werden getrennt summiert.",
"Die Zeitwerte zeigen getrackte Sessions und Teamspace für den jeweiligen Typ.", "Die Zeitwerte zeigen abgerechnete Sessions und Teamspace für den jeweiligen Typ.",
"Die Differenz folgt derselben Logik wie überall: Teamspace minus getrackte Sessions.", "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." "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: [ details: [
"Ticket-Hotspots helfen, die größten Aufwandstreiber im Monat schnell zu erkennen.", "Ticket-Hotspots helfen, die größten Aufwandstreiber im Monat schnell zu erkennen.",
"Die Liste zeigt die wichtigsten Tickets nach getrackter Zeit.", "Die Liste zeigt die wichtigsten Tickets nach abgerechneter Zeit.",
"Pro Ticket siehst du Organisation, Sessionanzahl, aktive Tage, getrackte Zeit, Teamspace-Zeit und Differenz.", "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.", "Offene Tickets werden mit einem Warnstatus angezeigt, damit Bewertungen nachgezogen werden können.",
"Über Öffnen gelangst du direkt in die Ticketdetailansicht des Monats." "Über Öffnen gelangst du direkt in die Ticketdetailansicht des Monats."
] ]
+4
View File
@@ -93,6 +93,8 @@ export type TicketSummary = {
work_type: WorkType | null; work_type: WorkType | null;
session_count: number; session_count: number;
total_minutes: number; total_minutes: number;
non_billable_minutes: number;
open_minutes: number;
open_count: number; open_count: number;
ticket_open_count: number; ticket_open_count: number;
billed_count: number; billed_count: number;
@@ -134,6 +136,8 @@ export type PeriodOverview = {
sessions: number; sessions: number;
minutes: number; minutes: number;
crmBilledMinutes: number; crmBilledMinutes: number;
nonBillableMinutes: number;
openMinutes: number;
openSessions: number; openSessions: number;
}; };
tickets: TicketSummary[]; 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 }> = [ const metricCards: Array<{ label: string; value: string | number; detail?: string; icon: LucideIcon }> = [
{ label: "Tickets", value: totals?.tickets ?? 0, icon: Ticket }, { label: "Tickets", value: totals?.tickets ?? 0, icon: Ticket },
{ label: "Sessions", value: totals?.sessions ?? 0, icon: Users }, { label: "Sessions", value: totals?.sessions ?? 0, icon: Users },
{ label: "Zeit", value: formatMinutes(totals?.minutes ?? 0), detail: `Teamspace ${formatMinutes(totals?.crmBilledMinutes ?? 0)}`, icon: Clock3 }, { label: "Abgerechnet", value: formatMinutes(totals?.minutes ?? 0), detail: `Teamspace ${formatMinutes(totals?.crmBilledMinutes ?? 0)}`, icon: Clock3 },
{ label: "Offen", value: totals?.openSessions ?? 0, icon: UserCheck } { 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 tickets = overview?.tickets ?? [];
const organizationOptions = useMemo(() => { const organizationOptions = useMemo(() => {
@@ -495,7 +496,7 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
</div> </div>
</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 }) => ( {metricCards.map(({ label, value, detail, icon: Icon }) => (
<Card key={label}> <Card key={label}>
<CardContent className="flex items-center gap-3 p-3"> <CardContent className="flex items-center gap-3 p-3">
@@ -515,9 +516,9 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
<Card> <Card>
<CardHeader className="gap-3 p-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0"> <CardHeader className="gap-3 p-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
<div> <div>
<CardTitle>Gesamtaufwand</CardTitle> <CardTitle>Abgerechneter Aufwand</CardTitle>
<CardDescription> <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> </CardDescription>
</div> </div>
<div className="flex items-center gap-2"> <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"> <div className="mt-3 flex flex-wrap gap-3 text-xs text-muted-foreground">
<span className="inline-flex items-center gap-1.5"> <span className="inline-flex items-center gap-1.5">
<span className="size-2 rounded-sm bg-neutral-300 dark:bg-neutral-700" /> <span className="size-2 rounded-sm bg-neutral-300 dark:bg-neutral-700" />
Minuten Abgerechnet
</span> </span>
<span className="inline-flex items-center gap-1.5"> <span className="inline-flex items-center gap-1.5">
<span className="h-0.5 w-4 bg-foreground" /> <span className="h-0.5 w-4 bg-foreground" />
@@ -749,8 +750,8 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
<TableHead>Organisation</TableHead> <TableHead>Organisation</TableHead>
<TableHead>Art</TableHead> <TableHead>Art</TableHead>
<TableHead>Sessions</TableHead> <TableHead>Sessions</TableHead>
<TableHead>Zeit</TableHead> <TableHead>Zeit abgerechnet</TableHead>
<TableHead>Abgerechnet</TableHead> <TableHead>Sessions abgerechnet</TableHead>
<TableHead>Nicht abrechenbar</TableHead> <TableHead>Nicht abrechenbar</TableHead>
<TableHead>Status</TableHead> <TableHead>Status</TableHead>
<TableHead /> <TableHead />
@@ -789,7 +790,7 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
<TableCell>{ticket.session_count}</TableCell> <TableCell>{ticket.session_count}</TableCell>
<TableCell>{formatMinutes(ticket.total_minutes)}</TableCell> <TableCell>{formatMinutes(ticket.total_minutes)}</TableCell>
<TableCell>{ticket.billed_count}</TableCell> <TableCell>{ticket.billed_count}</TableCell>
<TableCell>{ticket.non_billable_count}</TableCell> <TableCell>{ticket.non_billable_count} / {formatMinutes(ticket.non_billable_minutes)}</TableCell>
<TableCell> <TableCell>
<TicketStatus ticket={ticket} /> <TicketStatus ticket={ticket} />
</TableCell> </TableCell>
@@ -837,7 +838,7 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
</div> </div>
<div className="grid grid-cols-3 gap-2 text-sm"> <div className="grid grid-cols-3 gap-2 text-sm">
<div className="rounded-md bg-muted/50 p-2"> <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> <p className="font-medium">{formatMinutes(ticket.total_minutes)}</p>
</div> </div>
<div className="rounded-md bg-muted/50 p-2"> <div className="rounded-md bg-muted/50 p-2">
@@ -846,7 +847,7 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
</div> </div>
<div className="rounded-md bg-muted/50 p-2"> <div className="rounded-md bg-muted/50 p-2">
<p className="text-muted-foreground">Nicht</p> <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>
</div> </div>
<Button className="w-full" size="sm" variant="secondary" onClick={() => onNavigate(`/analysis/${periodType}/${period}/tickets/${ticket.id}`)}> <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> </div>
<p className="truncate text-sm text-muted-foreground">{item.organization_name}</p> <p className="truncate text-sm text-muted-foreground">{item.organization_name}</p>
<p className="flex flex-wrap gap-x-3 gap-y-1 text-xs"> <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> <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} {typeof item.delta_minutes === "number" ? <span className="text-muted-foreground">Differenz {formatTeamspaceDelta(item.delta_minutes)}</span> : null}
</p> </p>
@@ -331,7 +331,7 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
) : null} ) : null}
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-5"> <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="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="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"} /> <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 ? ( {visibleMissingCrmDays.length === 0 ? (
<p className="rounded-md border bg-muted/20 p-3 text-sm text-muted-foreground"> <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> </p>
) : null} ) : null}
</section> </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 teamspaceStrokeClass = "text-emerald-600 dark:text-emerald-300";
const trackedDotClass = "bg-sky-500 dark:bg-sky-400"; const trackedDotClass = "bg-sky-500 dark:bg-sky-400";
const teamspaceDotClass = "bg-emerald-500 dark:bg-emerald-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 openTextClass = "text-amber-700 dark:text-amber-300";
const organizationPageSize = 8; const organizationPageSize = 8;
@@ -112,20 +112,20 @@ function DeltaBadge({ minutes }: { minutes: number }) {
function deltaDescription(minutes: number) { function deltaDescription(minutes: number) {
if (minutes > 0) { if (minutes > 0) {
return "Teamspace liegt über den Sessions"; return "Teamspace liegt über der abgerechneten Zeit";
} }
if (minutes < 0) { 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 }) { function TimeComparison({ trackedMinutes, crmMinutes, sessions }: { trackedMinutes: number; crmMinutes: number; sessions?: number }) {
return ( return (
<div className="space-y-0.5 text-sm"> <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> <div className={`font-semibold ${teamspaceTextClass}`}>Teamspace {formatMinutes(crmMinutes)}</div>
{typeof sessions === "number" ? <div className="text-xs text-muted-foreground">{sessions} Session(s)</div> : null} {typeof sessions === "number" ? <div className="text-xs text-muted-foreground">{sessions} Session(s)</div> : null}
</div> </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"> <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="inline-flex items-center gap-1.5">
<span className={`size-2 rounded-sm ${trackedDotClass}`} /> <span className={`size-2 rounded-sm ${trackedDotClass}`} />
<span className={trackedTextClass}>Sessions</span> <span className={trackedTextClass}>Abgerechnet</span>
</span> </span>
<span className="inline-flex items-center gap-1.5"> <span className="inline-flex items-center gap-1.5">
<span className={`h-0.5 w-4 ${teamspaceDotClass}`} /> <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 className="text-red-700 dark:text-red-300">Differenz minus</span>
</span> </span>
<span className="inline-flex items-center gap-1.5"> <span className="inline-flex items-center gap-1.5">
<span className="size-2 rounded-full bg-violet-500 dark:bg-violet-400" /> <span className="size-2 rounded-full bg-rose-500 dark:bg-rose-400" />
<span className={evaluatedTextClass}>bewertet</span> <span className={nonBillableTextClass}>nicht abrechenbar</span>
</span> </span>
<span className="inline-flex items-center gap-1.5"> <span className="inline-flex items-center gap-1.5">
<span className="size-2 rounded-full bg-amber-500 dark:bg-amber-400" /> <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> </div>
<p className="truncate text-sm text-muted-foreground">{ticket.organization_name ?? "Keine Organisation"}</p> <p className="truncate text-sm text-muted-foreground">{ticket.organization_name ?? "Keine Organisation"}</p>
<div className="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-xs"> <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={teamspaceTextClass}>Teamspace {formatMinutes(ticket.crm_billed_minutes)}</span>
<span className={nonBillableTextClass}>Nicht {formatMinutes(ticket.non_billable_minutes)}</span>
<span className="text-muted-foreground">{ticket.sessions} Session(s)</span> <span className="text-muted-foreground">{ticket.sessions} Session(s)</span>
<span>{ticket.active_days} Tag(e)</span> <span>{ticket.active_days} Tag(e)</span>
<span className={deltaTextClass(ticket.crm_delta_minutes)}>{formatTeamspaceDelta(ticket.crm_delta_minutes)}</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 totals = stats?.totals;
const evaluatedSessions = (totals?.billedSessions ?? 0) + (totals?.nonBillableSessions ?? 0); const billedSessions = totals?.billedSessions ?? 0;
const evaluatedMinutes = (totals?.billedMinutes ?? 0) + (totals?.nonBillableMinutes ?? 0); const billedMinutes = totals?.billedMinutes ?? 0;
const [evaluatedSessionShare, openSessionShare] = roundedPercentages([evaluatedSessions, totals?.openSessions ?? 0]); const nonBillableSessions = totals?.nonBillableSessions ?? 0;
const [evaluatedTimeShare, openTimeShare] = roundedPercentages([evaluatedMinutes, totals?.openMinutes ?? 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 crmCoverage = percentage(totals?.crmBilledMinutes ?? 0, totals?.minutes ?? 0);
const maxOrganizationMinutes = Math.max(...(stats?.organizations ?? []).map((organization) => organization.total_minutes), 1); const maxOrganizationMinutes = Math.max(...(stats?.organizations ?? []).map((organization) => organization.total_minutes), 1);
const topOrganization = stats?.organizations[0] ?? null; 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"> <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<MetricCard <MetricCard
label="Getrackt" label="Abgerechnet"
value={formatMinutes(totals?.minutes ?? 0)} 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} icon={Clock3}
valueClassName={trackedTextClass} valueClassName={trackedTextClass}
helpAnchor="statistik-getrackt" helpAnchor="statistik-getrackt"
helpLabel="Hilfe zur Kennzahl Getrackt" helpLabel="Hilfe zur Kennzahl Abgerechnet"
/> />
<MetricCard <MetricCard
label="Teamspace" label="Teamspace"
@@ -514,25 +519,25 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
helpLabel="Hilfe zur Kennzahl Teamspace" helpLabel="Hilfe zur Kennzahl Teamspace"
/> />
<MetricCard <MetricCard
label="Bewertet" label="Nicht abrechenbar"
value={formatMinutes(evaluatedMinutes)} value={formatMinutes(nonBillableMinutes)}
detail={ detail={
<div className="space-y-0.5"> <div className="space-y-0.5">
<div>{evaluatedSessions} von {totals?.sessions ?? 0} Session(s), {evaluatedSessionShare}%</div> <div>{nonBillableSessions} von {totals?.sessions ?? 0} Session(s), {nonBillableSessionShare}%</div>
<div>{evaluatedTimeShare}% der Zeit · Abr. {totals?.billedSessions ?? 0} · Nicht {totals?.nonBillableSessions ?? 0}</div> <div>{nonBillableTimeShare}% der erfassten Zeit</div>
</div> </div>
} }
icon={ListChecks} icon={ListChecks}
valueClassName={evaluatedTextClass} valueClassName={nonBillableTextClass}
helpAnchor="statistik-bewertet" helpAnchor="statistik-bewertet"
helpLabel="Hilfe zur Kennzahl Bewertet" helpLabel="Hilfe zur Kennzahl Nicht abrechenbar"
/> />
<MetricCard <MetricCard
label="Offen" label="Offen"
value={formatMinutes(totals?.openMinutes ?? 0)} value={formatMinutes(openMinutes)}
detail={ detail={
<div className="space-y-0.5"> <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>{openTimeShare}% der Zeit</div>
</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"> <CardHeader className="gap-3 p-4 sm:flex-row sm:items-start sm:justify-between sm:space-y-0">
<div> <div>
<CardTitle>Monatsverlauf und Abrechnungsqualität</CardTitle> <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>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Badge variant="outline">{crmCoverage}% Teamspace-Abdeckung</Badge> <Badge variant="outline">{crmCoverage}% Teamspace-Abdeckung</Badge>
@@ -558,22 +563,24 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
<div className="space-y-3"> <div className="space-y-3">
<DailyEffortChart month={month} buckets={stats?.dailySeries ?? []} /> <DailyEffortChart month={month} buckets={stats?.dailySeries ?? []} />
<ColorLegend /> <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>
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-2 rounded-md border bg-muted/20 p-3"> <div className="space-y-2 rounded-md border bg-muted/20 p-3">
<div className="flex items-center justify-between gap-3 text-sm"> <div className="flex items-center justify-between gap-3 text-sm">
<span className="text-muted-foreground">Bewertungsstand</span> <span className="text-muted-foreground">Bewertungsstand</span>
<span className="font-medium">{evaluatedSessionShare}% / {openSessionShare}%</span> <span className="font-medium">{billedSessionShare}% / {nonBillableSessionShare}% / {openSessionShare}%</span>
</div> </div>
<div className="flex h-2 overflow-hidden rounded-full bg-muted"> <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 className="bg-amber-500 dark:bg-amber-400" style={{ width: `${openSessionShare}%` }} />
</div> </div>
<div className="grid gap-2 text-xs sm:grid-cols-2 xl:grid-cols-1 2xl:grid-cols-2"> <div className="grid gap-2 text-xs sm:grid-cols-3 xl:grid-cols-1 2xl:grid-cols-3">
<div className={evaluatedTextClass}>Bewertet: {evaluatedSessions} Session(s), {formatMinutes(evaluatedMinutes)}</div> <div className={trackedTextClass}>Abgerechnet: {billedSessions} Session(s), {formatMinutes(billedMinutes)}</div>
<div className={openTextClass}>Offen: {totals?.openSessions ?? 0} Session(s), {formatMinutes(totals?.openMinutes ?? 0)}</div> <div className={nonBillableTextClass}>Nicht: {nonBillableSessions} Session(s), {formatMinutes(nonBillableMinutes)}</div>
<div className={openTextClass}>Offen: {openSessions} Session(s), {formatMinutes(openMinutes)}</div>
</div> </div>
</div> </div>
@@ -586,7 +593,7 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
<DeltaBadge minutes={totals?.crmDeltaMinutes ?? 0} /> <DeltaBadge minutes={totals?.crmDeltaMinutes ?? 0} />
</div> </div>
<TimeComparison trackedMinutes={totals?.minutes ?? 0} crmMinutes={totals?.crmBilledMinutes ?? 0} sessions={totals?.sessions ?? 0} /> <TimeComparison trackedMinutes={totals?.minutes ?? 0} crmMinutes={totals?.crmBilledMinutes ?? 0} sessions={totals?.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>
<div className="grid grid-cols-2 gap-2 text-sm"> <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="text-muted-foreground">Top Organisation</p>
<p className="truncate font-semibold">{topOrganization.organization_name}</p> <p className="truncate font-semibold">{topOrganization.organization_name}</p>
<p className="flex flex-wrap gap-x-3 gap-y-1 text-xs"> <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={teamspaceTextClass}>Teamspace {formatMinutes(topOrganization.crm_billed_minutes)}</span>
<span className={nonBillableTextClass}>Nicht {formatMinutes(topOrganization.non_billable_minutes)}</span>
</p> </p>
</div> </div>
) : null} ) : 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"> <CardHeader className="gap-3 p-4 sm:flex-row sm:items-start sm:justify-between sm:space-y-0">
<div> <div>
<CardTitle>Statistik pro Organisation</CardTitle> <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>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Badge variant="outline"> <Badge variant="outline">
@@ -646,7 +654,7 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
<TableHead>Sessions</TableHead> <TableHead>Sessions</TableHead>
<TableHead>Zeiten</TableHead> <TableHead>Zeiten</TableHead>
<TableHead>Differenz</TableHead> <TableHead>Differenz</TableHead>
<TableHead>Anteil am Monat</TableHead> <TableHead>Anteil abgerechnet</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -673,8 +681,9 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
</div> </div>
<Progress value={percentage(organization.total_minutes, maxOrganizationMinutes)} /> <Progress value={percentage(organization.total_minutes, maxOrganizationMinutes)} />
<div className="flex flex-wrap gap-x-3 gap-y-1 text-xs"> <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={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="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> <span className={deltaTextClass(organization.crm_delta_minutes)}>{formatTeamspaceDelta(organization.crm_delta_minutes)}</span>
</div> </div>
@@ -742,7 +751,7 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
</div> </div>
<Progress value={percentage(group.total_minutes, totals?.minutes ?? 0)} /> <Progress value={percentage(group.total_minutes, totals?.minutes ?? 0)} />
<div className="flex flex-wrap gap-x-3 gap-y-1 text-xs"> <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> <span className={teamspaceTextClass}>Teamspace {formatMinutes(group.crm_billed_minutes)}</span>
</div> </div>
<div className="grid grid-cols-3 gap-2 text-xs text-muted-foreground"> <div className="grid grid-cols-3 gap-2 text-xs text-muted-foreground">
+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 savedDayBillingInput = (day: string) => formatHoursInput(dayBillingMap.get(day));
const isDayBillingDirty = (day: string) => (dayBillingInputs[day] ?? "") !== savedDayBillingInput(day); const isDayBillingDirty = (day: string) => (dayBillingInputs[day] ?? "") !== savedDayBillingInput(day);
const sessionGroups = useMemo(() => { 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 ?? []) { for (const session of data?.sessions ?? []) {
const key = sessionDayKey(session.started_at); const key = sessionDayKey(session.started_at);
const group = groups.get(key); 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) { if (group) {
group.sessions.push(session); group.sessions.push(session);
group.totalMinutes += session.rounded_minutes; group.totalMinutes += billedMinutes;
group.nonBillableMinutes += nonBillableMinutes;
group.openMinutes += openMinutes;
continue; continue;
} }
groups.set(key, { groups.set(key, {
key, key,
label: formatDate(session.started_at), label: formatDate(session.started_at),
totalMinutes: session.rounded_minutes, totalMinutes: billedMinutes,
nonBillableMinutes,
openMinutes,
sessions: [session] sessions: [session]
}); });
} }
@@ -551,7 +561,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate }: T
</div> </div>
<div className="flex flex-wrap items-center gap-2"> <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"> <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> <span className="font-semibold">{formatMinutes(periodTotalMinutes)}</span>
</div> </div>
<div className="flex items-center gap-2 rounded-md border bg-muted/30 px-3 py-2 text-sm"> <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"> <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> <p className="font-medium">{group.label}</p>
<div className="flex flex-wrap items-center gap-2"> <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"> <div className="flex items-center gap-2 text-xs text-muted-foreground">
<label htmlFor={`crm-billing-${group.key}`}>Teamspace</label> <label htmlFor={`crm-billing-${group.key}`}>Teamspace</label>
<Input <Input