diff --git a/backend/src/index.ts b/backend/src/index.ts index 512bf4d..51949c5 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -659,6 +659,330 @@ async function getPeriodOverview(config: PeriodConfig, period: ParsedPeriod, use }; } +async function getStatisticsOverview(period: ParsedPeriod, userId: string) { + await ensureRecurringSessionsForUserPeriod(userId, period.startIso, period.endIso); + + const baseCte = ` + WITH session_base AS ( + SELECT + s.id, + s.ticket_id, + t.ticket_number, + COALESCE(s.organization_id, t.organization_id) AS organization_id, + COALESCE(so.name, ot.name, s.customer_name, t.customer_name, 'Keine Organisation') AS organization_name, + s.activity, + s.work_type, + s.started_at, + (s.started_at AT TIME ZONE 'Europe/Berlin')::date AS day, + s.rounded_minutes, + s.billing_status, + s.recurring_billing_id + FROM sessions s + JOIN tickets t ON t.id = s.ticket_id + LEFT JOIN organizations so ON so.id = s.organization_id + LEFT JOIN organizations ot ON ot.id = t.organization_id + WHERE s.started_at >= $1::timestamptz + AND s.started_at < $2::timestamptz + AND s.user_id = $3 + ), + ticket_day AS ( + SELECT + ticket_id, + ticket_number, + day, + SUM(rounded_minutes)::int AS tracked_minutes + FROM session_base + GROUP BY ticket_id, ticket_number, day + ), + ticket_day_billing AS ( + SELECT + td.ticket_id, + td.ticket_number, + td.day, + td.tracked_minutes, + COALESCE(tdb.billed_minutes, 0)::int AS crm_billed_minutes, + tdb.billed_minutes IS NOT NULL AS has_crm_value + FROM ticket_day td + LEFT JOIN ticket_day_billings tdb + ON tdb.ticket_id = td.ticket_id + AND tdb.user_id = $3 + AND tdb.day = td.day + ), + session_group AS ( + SELECT + sb.organization_id, + sb.organization_name, + sb.work_type, + sb.ticket_id, + sb.ticket_number, + sb.day, + COUNT(*)::int AS session_count, + SUM(sb.rounded_minutes)::int AS total_minutes, + SUM(sb.rounded_minutes) FILTER (WHERE sb.billing_status = 'billed')::int AS billed_minutes, + SUM(sb.rounded_minutes) FILTER (WHERE sb.billing_status = 'non_billable')::int AS non_billable_minutes, + SUM(sb.rounded_minutes) FILTER (WHERE sb.billing_status IS NULL)::int AS open_minutes, + COUNT(*) FILTER (WHERE sb.billing_status IS NULL)::int AS open_count, + COUNT(*) FILTER (WHERE sb.recurring_billing_id IS NOT NULL)::int AS recurring_session_count, + COUNT(*) FILTER (WHERE sb.recurring_billing_id IS NULL)::int AS manual_session_count + FROM session_base sb + GROUP BY sb.organization_id, sb.organization_name, sb.work_type, sb.ticket_id, sb.ticket_number, sb.day + ), + session_group_with_crm AS ( + SELECT + sg.*, + ROUND(COALESCE(tdb.crm_billed_minutes, 0) * sg.total_minutes::numeric / NULLIF(tdb.tracked_minutes, 0))::int AS crm_billed_minutes + FROM session_group sg + LEFT JOIN ticket_day_billing tdb + ON tdb.ticket_id = sg.ticket_id + AND tdb.day = sg.day + ) + `; + + const [ + totalsResult, + dailyResult, + organizationResult, + workTypeResult, + ticketResult, + openResult, + missingCrmResult, + mismatchResult, + closureResult + ] = await Promise.all([ + query( + ` + ${baseCte} + SELECT + COUNT(DISTINCT ticket_id)::int AS tickets, + COUNT(*)::int AS sessions, + COALESCE(SUM(rounded_minutes), 0)::int AS minutes, + COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status = 'billed'), 0)::int AS billed_minutes, + COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status = 'non_billable'), 0)::int AS non_billable_minutes, + COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status IS NULL), 0)::int AS open_minutes, + COUNT(*) FILTER (WHERE billing_status IS NULL)::int AS open_sessions, + COALESCE(SUM(rounded_minutes) FILTER (WHERE recurring_billing_id IS NOT NULL), 0)::int AS recurring_minutes, + COALESCE(SUM(rounded_minutes) FILTER (WHERE recurring_billing_id IS NULL), 0)::int AS manual_minutes, + COALESCE(ROUND(AVG(rounded_minutes)), 0)::int AS average_session_minutes, + COUNT(DISTINCT day)::int AS active_days, + COALESCE((SELECT SUM(crm_billed_minutes)::int FROM ticket_day_billing), 0)::int AS crm_billed_minutes + FROM session_base; + `, + [period.startIso, period.endIso, userId] + ), + query( + ` + ${baseCte} + SELECT + td.day::text AS day, + COALESCE(day_sessions.session_count, 0)::int AS sessions, + td.tracked_minutes::int AS total_minutes, + COALESCE(day_sessions.billed_minutes, 0)::int AS billed_minutes, + COALESCE(day_sessions.non_billable_minutes, 0)::int AS non_billable_minutes, + COALESCE(day_sessions.open_minutes, 0)::int AS open_minutes, + COALESCE(SUM(tdb.crm_billed_minutes), 0)::int AS crm_billed_minutes + FROM ( + SELECT day, SUM(tracked_minutes)::int AS tracked_minutes + FROM ticket_day + GROUP BY day + ) td + LEFT JOIN ( + SELECT + day, + SUM(session_count)::int AS session_count, + SUM(COALESCE(billed_minutes, 0))::int AS billed_minutes, + SUM(COALESCE(non_billable_minutes, 0))::int AS non_billable_minutes, + SUM(COALESCE(open_minutes, 0))::int AS open_minutes + FROM session_group + GROUP BY day + ) day_sessions ON day_sessions.day = td.day + LEFT JOIN ticket_day_billing tdb ON tdb.day = td.day + GROUP BY td.day, td.tracked_minutes, day_sessions.session_count, day_sessions.billed_minutes, day_sessions.non_billable_minutes, day_sessions.open_minutes + ORDER BY td.day ASC; + `, + [period.startIso, period.endIso, userId] + ), + query( + ` + ${baseCte} + SELECT + organization_id::text AS organization_id, + organization_name, + COUNT(DISTINCT ticket_id)::int AS tickets, + SUM(session_count)::int AS sessions, + COALESCE(SUM(total_minutes), 0)::int AS total_minutes, + COALESCE(SUM(COALESCE(billed_minutes, 0)), 0)::int AS billed_minutes, + COALESCE(SUM(COALESCE(non_billable_minutes, 0)), 0)::int AS non_billable_minutes, + COALESCE(SUM(COALESCE(open_minutes, 0)), 0)::int AS open_minutes, + COALESCE(SUM(crm_billed_minutes), 0)::int AS crm_billed_minutes + FROM session_group_with_crm + GROUP BY organization_id, organization_name + ORDER BY total_minutes DESC, organization_name ASC; + `, + [period.startIso, period.endIso, userId] + ), + query( + ` + ${baseCte} + SELECT + work_type, + COUNT(DISTINCT ticket_id)::int AS tickets, + SUM(session_count)::int AS sessions, + COALESCE(SUM(total_minutes), 0)::int AS total_minutes, + COALESCE(SUM(COALESCE(billed_minutes, 0)), 0)::int AS billed_minutes, + COALESCE(SUM(COALESCE(non_billable_minutes, 0)), 0)::int AS non_billable_minutes, + COALESCE(SUM(COALESCE(open_minutes, 0)), 0)::int AS open_minutes, + COALESCE(SUM(crm_billed_minutes), 0)::int AS crm_billed_minutes + FROM session_group_with_crm + GROUP BY work_type + ORDER BY total_minutes DESC, work_type ASC; + `, + [period.startIso, period.endIso, userId] + ), + query( + ` + ${baseCte} + SELECT + ticket_id::text AS ticket_id, + ticket_number, + MIN(organization_id)::text AS organization_id, + MIN(organization_name) AS organization_name, + COUNT(DISTINCT day)::int AS active_days, + COUNT(*)::int AS sessions, + COALESCE(SUM(rounded_minutes), 0)::int AS total_minutes, + COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status = 'billed'), 0)::int AS billed_minutes, + COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status = 'non_billable'), 0)::int AS non_billable_minutes, + COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status IS NULL), 0)::int AS open_minutes, + COUNT(*) FILTER (WHERE billing_status IS NULL)::int AS open_sessions, + COALESCE((SELECT SUM(crm_billed_minutes)::int FROM ticket_day_billing tdb WHERE tdb.ticket_id = session_base.ticket_id), 0)::int AS crm_billed_minutes + FROM session_base + GROUP BY ticket_id, ticket_number + ORDER BY total_minutes DESC, ticket_number ASC + LIMIT 12; + `, + [period.startIso, period.endIso, userId] + ), + query( + ` + SELECT + s.id::text AS id, + s.ticket_id::text AS ticket_id, + t.ticket_number, + COALESCE(so.name, o.name, s.customer_name, t.customer_name, 'Keine Organisation') AS organization_name, + s.activity, + s.started_at, + s.rounded_minutes + FROM sessions s + JOIN tickets t ON t.id = s.ticket_id + LEFT JOIN organizations so ON so.id = s.organization_id + LEFT JOIN organizations o ON o.id = t.organization_id + WHERE s.started_at >= $1::timestamptz + AND s.started_at < $2::timestamptz + AND s.user_id = $3 + AND s.billing_status IS NULL + ORDER BY s.started_at ASC + LIMIT 12; + `, + [period.startIso, period.endIso, userId] + ), + query( + ` + ${baseCte} + SELECT + tdb.ticket_id::text AS ticket_id, + tdb.ticket_number, + MIN(sb.organization_name) AS organization_name, + tdb.day::text AS day, + tdb.tracked_minutes, + tdb.crm_billed_minutes + FROM ticket_day_billing tdb + JOIN session_base sb ON sb.ticket_id = tdb.ticket_id AND sb.day = tdb.day + WHERE NOT tdb.has_crm_value + GROUP BY tdb.ticket_id, tdb.ticket_number, tdb.day, tdb.tracked_minutes, tdb.crm_billed_minutes + ORDER BY tdb.day ASC, tdb.ticket_number ASC + LIMIT 12; + `, + [period.startIso, period.endIso, userId] + ), + query( + ` + ${baseCte} + SELECT + tdb.ticket_id::text AS ticket_id, + tdb.ticket_number, + MIN(sb.organization_name) AS organization_name, + tdb.day::text AS day, + tdb.tracked_minutes, + tdb.crm_billed_minutes, + (tdb.tracked_minutes - tdb.crm_billed_minutes)::int AS delta_minutes + FROM ticket_day_billing tdb + JOIN session_base sb ON sb.ticket_id = tdb.ticket_id AND sb.day = tdb.day + WHERE tdb.has_crm_value + AND tdb.tracked_minutes <> tdb.crm_billed_minutes + GROUP BY tdb.ticket_id, tdb.ticket_number, tdb.day, tdb.tracked_minutes, tdb.crm_billed_minutes + ORDER BY ABS(tdb.tracked_minutes - tdb.crm_billed_minutes) DESC, tdb.day ASC + LIMIT 12; + `, + [period.startIso, period.endIso, userId] + ), + query<{ closed_at: string }>("SELECT closed_at FROM month_closures WHERE month = $1::date AND user_id = $2;", [period.start, userId]) + ]); + + const totals = totalsResult.rows[0] ?? { + tickets: 0, + sessions: 0, + minutes: 0, + billed_minutes: 0, + non_billable_minutes: 0, + open_minutes: 0, + open_sessions: 0, + recurring_minutes: 0, + manual_minutes: 0, + average_session_minutes: 0, + active_days: 0, + crm_billed_minutes: 0 + }; + + return { + periodType: "month", + period: period.label, + closed: closureResult.rows.length > 0, + closedAt: closureResult.rows[0]?.closed_at ?? null, + totals: { + tickets: totals.tickets, + sessions: totals.sessions, + minutes: totals.minutes, + billedMinutes: totals.billed_minutes, + nonBillableMinutes: totals.non_billable_minutes, + openMinutes: totals.open_minutes, + openSessions: totals.open_sessions, + recurringMinutes: totals.recurring_minutes, + manualMinutes: totals.manual_minutes, + averageSessionMinutes: totals.average_session_minutes, + activeDays: totals.active_days, + crmBilledMinutes: totals.crm_billed_minutes, + crmDeltaMinutes: totals.minutes - totals.crm_billed_minutes + }, + dailySeries: dailyResult.rows, + organizations: organizationResult.rows.map((row: any) => ({ + ...row, + crm_delta_minutes: row.total_minutes - row.crm_billed_minutes + })), + workTypes: workTypeResult.rows.map((row: any) => ({ + ...row, + crm_delta_minutes: row.total_minutes - row.crm_billed_minutes + })), + tickets: ticketResult.rows.map((row: any) => ({ + ...row, + crm_delta_minutes: row.total_minutes - row.crm_billed_minutes + })), + attention: { + openSessions: openResult.rows, + missingCrmDays: missingCrmResult.rows, + crmMismatches: mismatchResult.rows + } + }; +} + async function getPeriodTicket(config: PeriodConfig, period: ParsedPeriod, ticketId: number, userId: string) { await ensureRecurringSessionsForUserPeriod(userId, period.startIso, period.endIso); @@ -2055,6 +2379,11 @@ app.get("/api/periods/:periodType/:period/overview", async (req, res) => { res.json(await getPeriodOverview(config, period, currentUser(req).id)); }); +app.get("/api/statistics/months/:month", async (req, res) => { + const month = parseMonth(req.params.month); + res.json(await getStatisticsOverview(month, currentUser(req).id)); +}); + app.get("/api/tickets/lookup", async (req, res) => { const ticketNumber = parseTicketNumber(req.query.ticketNumber); const result = await query( diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7eab626..c46f6b8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,6 +2,7 @@ import { BarChart3, CheckCircle2, Command, + ChartNoAxesCombined, LayoutGrid, LogOut, Moon, @@ -57,6 +58,7 @@ import { AnalysisPage } from "./views/AnalysisPage"; import { LoginPage } from "./views/LoginPage"; import { ProfilePage } from "./views/ProfilePage"; import { RecurringBillingsPage } from "./views/RecurringBillingsPage"; +import { StatisticsPage } from "./views/StatisticsPage"; import { TicketDetailPage } from "./views/TicketDetailPage"; import { TimerPage } from "./views/TimerPage"; import type { AuthUser, PeriodType } from "./types"; @@ -89,6 +91,10 @@ function routeFromPath(pathname: string) { return { page: "analysis" as const }; } + if (pathname.startsWith("/statistics")) { + return { page: "statistics" as const }; + } + if (pathname.startsWith("/recurring")) { return { page: "recurring" as const }; } @@ -501,6 +507,7 @@ export function App() { const navItems = [ { href: "/timer", label: "Timer", icon: Timer, active: path.startsWith("/timer") || path === "/" }, { href: "/analysis", label: "Auswertung", icon: BarChart3, active: path.startsWith("/analysis") }, + { href: "/statistics", label: "Statistiken", icon: ChartNoAxesCombined, active: path.startsWith("/statistics") }, { href: "/recurring", label: "Fixe Abrechnung", icon: Repeat, active: path.startsWith("/recurring") }, { href: "/profile", label: "Profil", icon: UserCog, active: path.startsWith("/profile") }, ...(currentUser.role === "admin" @@ -685,6 +692,7 @@ export function App() { /> ) : null} {route.page === "analysis" ? : null} + {route.page === "statistics" ? : null} {route.page === "recurring" ? : null} {route.page === "profile" ? : null} {route.page === "admin-users" && currentUser.role === "admin" ? : null} diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 666cd38..1dc84b7 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -7,6 +7,7 @@ import type { PeriodOverview, PeriodType, RecurringBilling, + StatisticsOverview, TicketMeta, TicketPeriod, UserRole, @@ -299,6 +300,10 @@ export function getPeriodOverview(type: PeriodType, period: string) { return request(`/api/periods/${periodPath(type)}/${period}/overview`); } +export function getStatisticsOverview(month: string) { + return request(`/api/statistics/months/${month}`); +} + export function getTicketPeriod(type: PeriodType, period: string, ticketId: string) { return request(`/api/periods/${periodPath(type)}/${period}/tickets/${ticketId}`); } diff --git a/frontend/src/types.ts b/frontend/src/types.ts index be7a334..4b02f5d 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -191,3 +191,94 @@ export type TicketPeriod = { export type MonthOverview = PeriodOverview; export type TicketMonth = TicketPeriod; + +export type StatisticsDailyBucket = { + day: string; + sessions: number; + total_minutes: number; + billed_minutes: number; + non_billable_minutes: number; + open_minutes: number; + crm_billed_minutes: number; +}; + +export type StatisticsGroup = { + organization_id?: string | null; + organization_name?: string; + work_type?: WorkType; + tickets: number; + sessions: number; + total_minutes: number; + billed_minutes: number; + non_billable_minutes: number; + open_minutes: number; + crm_billed_minutes: number; + crm_delta_minutes: number; +}; + +export type StatisticsTicket = { + ticket_id: string; + ticket_number: string; + organization_id: string | null; + organization_name: string; + active_days: number; + sessions: number; + total_minutes: number; + billed_minutes: number; + non_billable_minutes: number; + open_minutes: number; + open_sessions: number; + crm_billed_minutes: number; + crm_delta_minutes: number; +}; + +export type StatisticsOpenSession = { + id: string; + ticket_id: string; + ticket_number: string; + organization_name: string; + activity: string; + started_at: string; + rounded_minutes: number; +}; + +export type StatisticsCrmDay = { + ticket_id: string; + ticket_number: string; + organization_name: string; + day: string; + tracked_minutes: number; + crm_billed_minutes: number; + delta_minutes?: number; +}; + +export type StatisticsOverview = { + periodType: "month"; + period: string; + closed: boolean; + closedAt: string | null; + totals: { + tickets: number; + sessions: number; + minutes: number; + billedMinutes: number; + nonBillableMinutes: number; + openMinutes: number; + openSessions: number; + recurringMinutes: number; + manualMinutes: number; + averageSessionMinutes: number; + activeDays: number; + crmBilledMinutes: number; + crmDeltaMinutes: number; + }; + dailySeries: StatisticsDailyBucket[]; + organizations: StatisticsGroup[]; + workTypes: StatisticsGroup[]; + tickets: StatisticsTicket[]; + attention: { + openSessions: StatisticsOpenSession[]; + missingCrmDays: StatisticsCrmDay[]; + crmMismatches: StatisticsCrmDay[]; + }; +}; diff --git a/frontend/src/views/StatisticsPage.tsx b/frontend/src/views/StatisticsPage.tsx new file mode 100644 index 0000000..47b4229 --- /dev/null +++ b/frontend/src/views/StatisticsPage.tsx @@ -0,0 +1,658 @@ +import { + ArrowDownRight, + ArrowUpRight, + Building2, + CheckCircle2, + ChevronLeft, + ChevronRight, + CircleAlert, + Clock3, + ExternalLink, + FileWarning, + ListChecks, + RotateCcw, + Sparkles, + Ticket, + TrendingUp, +} from "lucide-react"; +import type { LucideIcon } from "lucide-react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { toast } from "sonner"; + +import { Alert } from "@/components/ui/alert"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Progress } from "@/components/ui/progress"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { getStatisticsOverview } from "../api"; +import { currentMonth, formatDate, formatDateTime, formatMinutes } from "../format"; +import type { StatisticsCrmDay, StatisticsDailyBucket, StatisticsGroup, StatisticsOverview, StatisticsTicket, WorkType } from "../types"; + +type StatisticsPageProps = { + onNavigate: (to: string) => void; +}; + +function workTypeLabel(workType: WorkType | undefined) { + if (workType === "support") { + return "Support"; + } + + if (workType === "consulting") { + return "Consulting"; + } + + return "Ohne Typ"; +} + +function percentage(value: number, total: number) { + if (total <= 0) { + return 0; + } + + return Math.max(0, Math.min(100, Math.round((value / total) * 100))); +} + +function formatSignedMinutes(minutes: number) { + if (minutes === 0) { + return "ausgeglichen"; + } + + return `${minutes > 0 ? "+" : "-"}${formatMinutes(Math.abs(minutes))}`; +} + +function formatAxisMinutes(value: number) { + const minutes = Math.round(value); + + if (minutes <= 0) { + return "0"; + } + + if (minutes >= 60) { + return `${new Intl.NumberFormat("de-DE", { maximumFractionDigits: minutes < 600 ? 1 : 0 }).format(minutes / 60)} h`; + } + + return `${minutes} min`; +} + +function niceCeilMinutes(value: number) { + if (value <= 30) { + return Math.max(5, Math.ceil(value / 5) * 5); + } + + if (value <= 120) { + return Math.ceil(value / 15) * 15; + } + + if (value <= 480) { + return Math.ceil(value / 30) * 30; + } + + return Math.ceil(value / 60) * 60; +} + +function buildChartPath(points: Array<{ x: number; y: number }>) { + if (points.length === 0) { + return ""; + } + + if (points.length === 1) { + return `M ${points[0].x - 18} ${points[0].y} L ${points[0].x + 18} ${points[0].y}`; + } + + return points.map((point, index) => `${index === 0 ? "M" : "L"} ${point.x} ${point.y}`).join(" "); +} + +function buildMonthSeries(month: string, buckets: StatisticsDailyBucket[]) { + const series = new Map(buckets.map((bucket) => [bucket.day, bucket])); + const [year, monthNumber] = month.split("-").map(Number); + const daysInMonth = Number.isFinite(year) && Number.isFinite(monthNumber) ? new Date(year, monthNumber, 0).getDate() : 31; + + return Array.from({ length: daysInMonth }).map((_, index) => { + const day = String(index + 1).padStart(2, "0"); + const key = `${month}-${day}`; + const bucket = series.get(key); + + return { + day: key, + label: day, + sessions: bucket?.sessions ?? 0, + totalMinutes: bucket?.total_minutes ?? 0, + billedMinutes: bucket?.billed_minutes ?? 0, + nonBillableMinutes: bucket?.non_billable_minutes ?? 0, + openMinutes: bucket?.open_minutes ?? 0, + crmBilledMinutes: bucket?.crm_billed_minutes ?? 0 + }; + }); +} + +function DailyEffortChart({ month, buckets }: { month: string; buckets: StatisticsDailyBucket[] }) { + const series = buildMonthSeries(month, buckets); + const width = 900; + const height = 220; + const padding = { top: 18, right: 20, bottom: 36, left: 64 }; + const innerWidth = width - padding.left - padding.right; + const innerHeight = height - padding.top - padding.bottom; + const maxMinutes = niceCeilMinutes(Math.max(...series.map((bucket) => Math.max(bucket.totalMinutes, bucket.crmBilledMinutes)), 1)); + const ticks = Array.from({ length: 5 }).map((_, index) => maxMinutes - (index * maxMinutes) / 4); + const labelStep = Math.max(1, Math.ceil(series.length / 9)); + const barWidth = Math.max(5, Math.min(16, innerWidth / Math.max(series.length, 1) / 2)); + const points = series.map((bucket, index) => { + const x = series.length === 1 ? padding.left + innerWidth / 2 : padding.left + (index * innerWidth) / (series.length - 1); + + return { + bucket, + x, + totalBarHeight: bucket.totalMinutes > 0 ? Math.max(2, (bucket.totalMinutes / maxMinutes) * innerHeight) : 0, + crmY: padding.top + (1 - bucket.crmBilledMinutes / maxMinutes) * innerHeight + }; + }); + const crmPath = buildChartPath(points.filter((point) => point.bucket.crmBilledMinutes > 0).map((point) => ({ x: point.x, y: point.crmY }))); + + return ( +
+ {series.some((bucket) => bucket.totalMinutes > 0 || bucket.crmBilledMinutes > 0) ? ( + + {ticks.map((tick) => { + const y = padding.top + (1 - tick / maxMinutes) * innerHeight; + + return ( + + + + {formatAxisMinutes(tick)} + + + ); + })} + + {points.map((point) => { + const y = padding.top + innerHeight - point.totalBarHeight; + + return point.totalBarHeight > 0 ? ( + + ) : null; + })} + {crmPath ? : null} + {points + .filter((point) => point.bucket.crmBilledMinutes > 0) + .map((point) => ( + + ))} + {points.map((point, index) => + index % labelStep === 0 || index === points.length - 1 ? ( + + {point.bucket.label} + + ) : null + )} + + ) : ( +
Keine Statistikdaten für diesen Monat.
+ )} +
+ ); +} + +function MetricCard({ label, value, detail, icon: Icon }: { label: string; value: string | number; detail?: string; icon: LucideIcon }) { + return ( + + +
+ +
+
+

{label}

+

{value}

+ {detail ?

{detail}

: null} +
+
+
+ ); +} + +function OrganizationRow({ organization, maxMinutes }: { organization: StatisticsGroup; maxMinutes: number }) { + return ( + + +
{organization.organization_name ?? "Keine Organisation"}
+
{organization.tickets} Ticket(s) · {organization.sessions} Session(s)
+
+ {formatMinutes(organization.total_minutes)} + {formatMinutes(organization.crm_billed_minutes)} + + {formatSignedMinutes(organization.crm_delta_minutes)} + + + + +
+ ); +} + +function TicketHotspot({ ticket, month, onNavigate }: { ticket: StatisticsTicket; month: string; onNavigate: (to: string) => void }) { + return ( +
+
+
+ {ticket.ticket_number} + {ticket.open_sessions > 0 ? {ticket.open_sessions} offen : bewertet} +
+

{ticket.organization_name ?? "Keine Organisation"}

+
+ {formatMinutes(ticket.total_minutes)} getrackt + Teamspace {formatMinutes(ticket.crm_billed_minutes)} + {ticket.active_days} Tag(e) +
+
+ +
+ ); +} + +function CrmDayItem({ item, onNavigate }: { item: StatisticsCrmDay; onNavigate: (to: string) => void }) { + return ( +
+
+
+ {item.ticket_number} + {formatDate(`${item.day}T00:00:00`)} +
+

{item.organization_name}

+

+ Getrackt {formatMinutes(item.tracked_minutes)} · Teamspace {formatMinutes(item.crm_billed_minutes)} + {typeof item.delta_minutes === "number" ? ` · Differenz ${formatSignedMinutes(item.delta_minutes)}` : ""} +

+
+ +
+ ); +} + +export function StatisticsPage({ onNavigate }: StatisticsPageProps) { + const [month, setMonth] = useState(currentMonth()); + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(false); + const loadRequestId = useRef(0); + + async function load() { + const requestId = loadRequestId.current + 1; + loadRequestId.current = requestId; + setLoading(true); + + try { + const result = await getStatisticsOverview(month); + + if (requestId === loadRequestId.current) { + setStats(result); + } + } catch (error) { + if (requestId === loadRequestId.current) { + toast.error("Statistiken konnten nicht geladen werden", { + description: error instanceof Error ? error.message : "Unbekannter Fehler" + }); + } + } finally { + if (requestId === loadRequestId.current) { + setLoading(false); + } + } + } + + useEffect(() => { + void load(); + }, [month]); + + useEffect(() => { + function refreshVisible() { + if (document.visibilityState === "visible") { + void load(); + } + } + + const interval = window.setInterval(refreshVisible, 60_000); + window.addEventListener("focus", refreshVisible); + document.addEventListener("visibilitychange", refreshVisible); + + return () => { + window.clearInterval(interval); + window.removeEventListener("focus", refreshVisible); + document.removeEventListener("visibilitychange", refreshVisible); + }; + }, [month]); + + function changeMonthBy(delta: number) { + const [year, monthNumber] = month.split("-").map(Number); + const date = Number.isFinite(year) && Number.isFinite(monthNumber) ? new Date(year, monthNumber - 1, 1) : new Date(); + date.setMonth(date.getMonth() + delta); + setMonth(`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`); + } + + const totals = stats?.totals; + const billableShare = percentage(totals?.billedMinutes ?? 0, totals?.minutes ?? 0); + const openShare = percentage(totals?.openMinutes ?? 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 topOrganization = stats?.organizations[0] ?? null; + const strongestDay = useMemo(() => { + return [...(stats?.dailySeries ?? [])].sort((left, right) => right.total_minutes - left.total_minutes)[0] ?? null; + }, [stats?.dailySeries]); + const attentionCount = + (stats?.attention.openSessions.length ?? 0) + + (stats?.attention.missingCrmDays.length ?? 0) + + (stats?.attention.crmMismatches.length ?? 0); + + return ( +
+
+
+

Statistiken

+

Monatswerte, Teamspace-Abgleich, Organisationen und offene Nacharbeit.

+
+
+
+ + {loading ? lädt... : null} +
+
+ + setMonth(event.currentTarget.value)} /> + +
+
+
+ + {stats?.closed ? ( + + + Dieser Monat wurde am {formatDateTime(stats.closedAt!)} abgeschlossen. + + ) : null} + +
+ + + + +
+ +
+ + +
+ Monatsverlauf + Getrackter Aufwand pro Tag mit Teamspace-Linie. +
+ {crmCoverage}% Teamspace-Abdeckung +
+ + +
+ + + Getrackt + + + + Teamspace + + {strongestDay ? Stärkster Tag: {formatDate(`${strongestDay.day}T00:00:00`)} mit {formatMinutes(strongestDay.total_minutes)} : null} +
+
+
+ + + + Abrechnungsqualität + Bewertung, CRM-Abdeckung und Erfassungsart. + + +
+
+ Abgerechnet + {billableShare}% +
+ +
+
+
+ Teamspace + {crmCoverage}% +
+ +
+
+
+

Manuell

+

{formatMinutes(totals?.manualMinutes ?? 0)}

+
+
+

Fix

+

{formatMinutes(totals?.recurringMinutes ?? 0)}

+
+
+

Aktive Tage

+

{totals?.activeDays ?? 0}

+
+
+

Schnitt

+

{formatMinutes(totals?.averageSessionMinutes ?? 0)}

+
+
+ {topOrganization ? ( +
+

Top Organisation

+

{topOrganization.organization_name}

+

{formatMinutes(topOrganization.total_minutes)}

+
+ ) : null} +
+
+
+ +
+ + + Statistik pro Organisation + Kundenaufwand inklusive Teamspace-Differenz. + + +
+ + + + Organisation + Zeit + Teamspace + Differenz + Anteil + + + + {(stats?.organizations ?? []).map((organization) => ( + + ))} + +
+
+
+ {(stats?.organizations ?? []).map((organization) => ( +
+
+
+

{organization.organization_name ?? "Keine Organisation"}

+

{organization.tickets} Ticket(s) · {organization.sessions} Session(s)

+
+ {formatSignedMinutes(organization.crm_delta_minutes)} +
+ +

{formatMinutes(organization.total_minutes)} getrackt · Teamspace {formatMinutes(organization.crm_billed_minutes)}

+
+ ))} +
+ {stats && stats.organizations.length === 0 ?

Keine Organisationen im gewählten Monat.

: null} +
+
+ + + + Typen + Support und Consulting im direkten Vergleich. + + + {(stats?.workTypes ?? []).map((group) => ( +
+
+
+

{workTypeLabel(group.work_type)}

+

{group.tickets} Ticket(s), {group.sessions} Session(s)

+
+ {formatMinutes(group.total_minutes)} +
+ +
+ Abr. {formatMinutes(group.billed_minutes)} + Nicht {formatMinutes(group.non_billable_minutes)} + Offen {formatMinutes(group.open_minutes)} +
+
+ ))} + {stats && stats.workTypes.length === 0 ?

Noch keine Typdaten vorhanden.

: null} +
+
+
+ +
+ + +
+ Ticket-Hotspots + Die größten Zeitblöcke im Monat. +
+ +
+ + {(stats?.tickets ?? []).map((ticket) => ( + + ))} + {stats && stats.tickets.length === 0 ?

Keine Tickets im gewählten Monat.

: null} +
+
+ + + +
+ Aufräumen + Alles, was vor dem Monatsabschluss Aufmerksamkeit braucht. +
+ 0 ? "warning" : "success"}>{attentionCount} Punkt(e) +
+ +
+
+ + Offene Bewertungen +
+ {(stats?.attention.openSessions ?? []).slice(0, 5).map((session) => ( +
+
+

{session.ticket_number}

+

{session.organization_name}

+

{session.activity}

+
+ +
+ ))} + {stats?.attention.openSessions.length === 0 ?

Keine offenen Bewertungen.

: null} +
+ +
+
+ + Tage ohne Teamspace-Wert +
+ {(stats?.attention.missingCrmDays ?? []).slice(0, 4).map((item) => ( + + ))} + {stats?.attention.missingCrmDays.length === 0 ?

Alle getrackten Tage haben einen Teamspace-Wert.

: null} +
+ +
+
+ + Auffällige Differenzen +
+ {(stats?.attention.crmMismatches ?? []).slice(0, 4).map((item) => ( + + ))} + {stats?.attention.crmMismatches.length === 0 ?

Keine Abweichungen zwischen Tracking und Teamspace.

: null} +
+
+
+
+ +
+ + + +
+

Mehr getrackt als Teamspace

+

{formatMinutes(Math.max(totals?.crmDeltaMinutes ?? 0, 0))}

+
+
+
+ + + +
+

Mehr Teamspace als getrackt

+

{formatMinutes(Math.max(-(totals?.crmDeltaMinutes ?? 0), 0))}

+
+
+
+ + + +
+

Organisationen

+

{stats?.organizations.length ?? 0}

+
+
+
+
+ + {stats && stats.totals.sessions === 0 ? ( + + + +
+

Keine Sessions in diesem Monat

+

Sobald Sessions vorhanden sind, füllt sich die Statistik automatisch.

+
+ +
+
+ ) : null} +
+ ); +}