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