From ca5e0150659979a6a39afaabbc51c67e366faec2 Mon Sep 17 00:00:00 2001 From: mboehmlaender Date: Wed, 5 Aug 2026 15:05:25 +0200 Subject: [PATCH] Move month close workflow --- backend/src/index.ts | 9 +- frontend/src/App.tsx | 10 +- frontend/src/views/AnalysisPage.tsx | 117 +------- frontend/src/views/MonthlyClosePage.tsx | 330 +++++++++++++++++++++ frontend/src/views/StatisticsPage.tsx | 372 +++++++++++++++--------- 5 files changed, 578 insertions(+), 260 deletions(-) create mode 100644 frontend/src/views/MonthlyClosePage.tsx diff --git a/backend/src/index.ts b/backend/src/index.ts index 9e88ce0..7c21904 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -899,8 +899,7 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string) { 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; + ORDER BY s.started_at ASC; `, [period.startIso, period.endIso, userId] ), @@ -918,8 +917,7 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string) { 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; + ORDER BY tdb.day ASC, tdb.ticket_number ASC; `, [period.startIso, period.endIso, userId] ), @@ -939,8 +937,7 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string) { 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; + ORDER BY ABS(tdb.tracked_minutes - tdb.crm_billed_minutes) DESC, tdb.day ASC; `, [period.startIso, period.endIso, userId] ), diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c46f6b8..43faf96 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,6 +1,7 @@ import { BarChart3, CheckCircle2, + ClipboardCheck, Command, ChartNoAxesCombined, LayoutGrid, @@ -56,6 +57,7 @@ import { activeElapsedMs, pauseEntry, readStoredTimers, resumeEntry, storageKeyF import { AdminUsersPage } from "./views/AdminUsersPage"; import { AnalysisPage } from "./views/AnalysisPage"; import { LoginPage } from "./views/LoginPage"; +import { MonthlyClosePage } from "./views/MonthlyClosePage"; import { ProfilePage } from "./views/ProfilePage"; import { RecurringBillingsPage } from "./views/RecurringBillingsPage"; import { StatisticsPage } from "./views/StatisticsPage"; @@ -95,6 +97,10 @@ function routeFromPath(pathname: string) { return { page: "statistics" as const }; } + if (pathname.startsWith("/monthly-close")) { + return { page: "monthly-close" as const }; + } + if (pathname.startsWith("/recurring")) { return { page: "recurring" as const }; } @@ -507,6 +513,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: "/monthly-close", label: "Monatsabschluss", icon: ClipboardCheck, active: path.startsWith("/monthly-close") }, { 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") }, @@ -595,7 +602,7 @@ export function App() { Abschluss -

Offene Sessions findest du in Tages- und Monatsansicht.

+

Offene Bewertungen findest du im Monatsabschluss.

@@ -692,6 +699,7 @@ export function App() { /> ) : null} {route.page === "analysis" ? : null} + {route.page === "monthly-close" ? : null} {route.page === "statistics" ? : null} {route.page === "recurring" ? : null} {route.page === "profile" ? : null} diff --git a/frontend/src/views/AnalysisPage.tsx b/frontend/src/views/AnalysisPage.tsx index 1c2265b..333b209 100644 --- a/frontend/src/views/AnalysisPage.tsx +++ b/frontend/src/views/AnalysisPage.tsx @@ -1,8 +1,7 @@ -import { CheckCircle2, ChevronLeft, ChevronRight, CircleAlert, Clock3, ExternalLink, LockOpen, RotateCcw, SlidersHorizontal, Ticket, UserCheck, Users } from "lucide-react"; +import { ChevronLeft, ChevronRight, Clock3, ExternalLink, RotateCcw, SlidersHorizontal, Ticket, UserCheck, Users } from "lucide-react"; import type { LucideIcon } from "lucide-react"; import { Fragment, 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"; @@ -10,8 +9,8 @@ import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { CopyTicketButton } from "@/components/CopyTicketButton"; -import { closePeriod, getPeriodOverview, reopenPeriod } from "../api"; -import { currentDay, currentMonth, formatDateTime, formatMinutes } from "../format"; +import { getPeriodOverview } from "../api"; +import { currentDay, currentMonth, formatMinutes } from "../format"; import type { PeriodOverview, PeriodType, TicketSummary } from "../types"; type AnalysisPageProps = { @@ -304,9 +303,6 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) { const [period, setPeriod] = useState(currentMonth()); const [overview, setOverview] = useState(null); const [loading, setLoading] = useState(false); - const [closing, setClosing] = useState(false); - const [reopening, setReopening] = useState(false); - const [showOpen, setShowOpen] = useState(false); const [ticketViewSettings, setTicketViewSettings] = useState(() => readStoredTicketViewSettings()); const loadRequestId = useRef(0); @@ -362,7 +358,6 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) { function switchPeriodType(nextType: PeriodType) { setPeriodType(nextType); setPeriod(nextType === "month" ? currentMonth() : currentDay()); - setShowOpen(false); } function changePeriodBy(delta: number) { @@ -381,48 +376,10 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) { setPeriod(`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`); } - setShowOpen(false); - } - - async function finishPeriod() { - setClosing(true); - try { - await closePeriod(periodType, period); - toast.success(`${periodType === "month" ? "Monat" : "Tag"} abgeschlossen`, { - description: `${period} ist abgeschlossen.` - }); - await load(); - } catch (error) { - toast.error(`${periodType === "month" ? "Monat" : "Tag"} noch nicht abschließbar`, { - description: error instanceof Error ? error.message : "Bitte offene Sessions prüfen." - }); - setShowOpen(true); - await load(); - } finally { - setClosing(false); - } - } - - async function openPeriodAgain() { - setReopening(true); - try { - await reopenPeriod(periodType, period); - toast.success(`${periodType === "month" ? "Monat" : "Tag"} wieder geöffnet`, { - description: `${period} kann wieder bearbeitet werden.` - }); - await load(); - } catch (error) { - toast.error(`${periodType === "month" ? "Monat" : "Tag"} konnte nicht geöffnet werden`, { - description: error instanceof Error ? error.message : "Unbekannter Fehler" - }); - } finally { - setReopening(false); - } } const totals = overview?.totals; const periodLabel = periodType === "month" ? "Monat" : "Tag"; - const supportsPeriodClosure = periodType === "month"; const metricCards: Array<{ label: string; value: string | number; detail?: string; icon: LucideIcon }> = [ { label: "Tickets", value: totals?.tickets ?? 0, icon: Ticket }, { label: "Sessions", value: totals?.sessions ?? 0, icon: Users }, @@ -499,7 +456,7 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {

Auswertung

- {supportsPeriodClosure ? "Tickets prüfen, Sessions markieren und Monat abschließen." : "Sessions des Tages prüfen und bewerten."} + {periodType === "month" ? "Tickets prüfen, Sessions bewerten und Zeiten kontrollieren." : "Sessions des Tages prüfen und bewerten."}

@@ -527,10 +484,7 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) { className="h-9" type={periodType === "month" ? "month" : "date"} value={period} - onChange={(event) => { - setPeriod(event.currentTarget.value); - setShowOpen(false); - }} + onChange={(event) => setPeriod(event.currentTarget.value)} /> - - ) : null} - - {showOpen && overview?.openSessions.length ? ( - - - Nicht bearbeitete Sessions - Diese Einträge blockieren den Monatsabschluss. - - - {overview.openSessions.map((session) => ( -
-
-
- {session.ticket_number} - {formatMinutes(session.rounded_minutes)} -
-

{session.customer_name}

-

{session.activity}

-
- -
- ))} -
-
- ) : null} -
Tickets im Zeitraum - {supportsPeriodClosure ? "Sessions prüfen und danach den Monat abschließen." : "Tagesansicht ohne eigenen Abschluss."} + {periodType === "month" ? "Sessions prüfen, bewerten und bei Bedarf Tickets öffnen." : "Tagesansicht ohne eigenen Abschluss."}
- {supportsPeriodClosure ? ( - overview?.closed ? ( - - ) : ( - - ) - ) : null}
diff --git a/frontend/src/views/MonthlyClosePage.tsx b/frontend/src/views/MonthlyClosePage.tsx new file mode 100644 index 0000000..76e005e --- /dev/null +++ b/frontend/src/views/MonthlyClosePage.tsx @@ -0,0 +1,330 @@ +import { + AlertTriangle, + CheckCircle2, + ChevronLeft, + ChevronRight, + Clock3, + ExternalLink, + FileWarning, + Lock, + LockOpen, + Sparkles, + TrendingUp, +} 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 { closePeriod, getStatisticsOverview, reopenPeriod } from "../api"; +import { currentMonth, formatDate, formatDateTime, formatMinutes } from "../format"; +import type { StatisticsCrmDay, StatisticsOpenSession, StatisticsOverview } from "../types"; + +type MonthlyClosePageProps = { + onNavigate: (to: string) => void; +}; + +const trackedTextClass = "text-sky-700 dark:text-sky-300"; +const teamspaceTextClass = "text-emerald-700 dark:text-emerald-300"; + +function formatSignedMinutes(minutes: number) { + if (minutes === 0) { + return "ausgeglichen"; + } + + return `${minutes > 0 ? "+" : "-"}${formatMinutes(Math.abs(minutes))}`; +} + +function formatTeamspaceDelta(minutes: number) { + if (minutes === 0) { + return "ausgeglichen"; + } + + return `TS ${formatSignedMinutes(minutes)}`; +} + +function StatusCard({ label, value, detail, tone }: { label: string; value: string | number; detail: string; tone?: "ok" | "warn" | "neutral" }) { + const valueClassName = tone === "ok" ? "text-emerald-700 dark:text-emerald-300" : tone === "warn" ? "text-amber-700 dark:text-amber-300" : ""; + + return ( + + +

{label}

+

{value}

+

{detail}

+
+
+ ); +} + +function OpenSessionItem({ session, month, onNavigate }: { session: StatisticsOpenSession; month: string; onNavigate: (to: string) => void }) { + return ( +
+
+
+ {session.ticket_number} + {formatMinutes(session.rounded_minutes)} + offen +
+

{session.organization_name}

+

{session.activity}

+
+ +
+ ); +} + +function CrmDayItem({ item, onNavigate }: { item: StatisticsCrmDay; onNavigate: (to: string) => void }) { + return ( +
+
+
+ {item.ticket_number} + {formatDate(`${item.day}T00:00:00`)} +
+

{item.organization_name}

+

+ Sessions {formatMinutes(item.tracked_minutes)} + Teamspace {formatMinutes(item.crm_billed_minutes)} + {typeof item.delta_minutes === "number" ? Differenz {formatTeamspaceDelta(item.delta_minutes)} : null} +

+
+ +
+ ); +} + +export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) { + const [month, setMonth] = useState(currentMonth()); + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(false); + const [closing, setClosing] = useState(false); + const [reopening, setReopening] = 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("Monatsabschluss konnte nicht geladen werden", { + description: error instanceof Error ? error.message : "Unbekannter Fehler" + }); + } + } finally { + if (requestId === loadRequestId.current) { + setLoading(false); + } + } + } + + useEffect(() => { + void load(); + }, [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")}`); + } + + async function finishMonth() { + setClosing(true); + try { + await closePeriod("month", month); + toast.success("Monat abgeschlossen", { + description: `${month} ist abgeschlossen.` + }); + await load(); + } catch (error) { + toast.error("Monat noch nicht abschließbar", { + description: error instanceof Error ? error.message : "Bitte offene Bewertungen prüfen." + }); + await load(); + } finally { + setClosing(false); + } + } + + async function reopenMonth() { + setReopening(true); + try { + await reopenPeriod("month", month); + toast.success("Monat wieder geöffnet", { + description: `${month} kann wieder bearbeitet werden.` + }); + await load(); + } catch (error) { + toast.error("Monat konnte nicht geöffnet werden", { + description: error instanceof Error ? error.message : "Unbekannter Fehler" + }); + } finally { + setReopening(false); + } + } + + const openSessions = stats?.attention.openSessions ?? []; + const missingCrmDays = stats?.attention.missingCrmDays ?? []; + const crmMismatches = stats?.attention.crmMismatches ?? []; + const canClose = Boolean(stats && !stats.closed && stats.totals.sessions > 0 && openSessions.length === 0); + const checklist = useMemo( + () => [ + { label: "Offene Bewertungen", count: openSessions.length, blocker: true }, + { label: "Tage ohne Teamspace-Wert", count: missingCrmDays.length, blocker: false }, + { label: "Teamspace-Differenzen", count: crmMismatches.length, blocker: false } + ], + [openSessions.length, missingCrmDays.length, crmMismatches.length] + ); + + return ( +
+
+
+

Monatsabschluss

+

Offene Bewertungen, Teamspace-Abgleich und der eigentliche Monatsabschluss.

+
+
+
+
+ + {loading ? lädt... : null} +
+
+ + setMonth(event.currentTarget.value)} /> + +
+
+ {stats?.closed ? ( + + ) : ( + + )} +
+
+ + {stats?.closed ? ( + + + Dieser Monat wurde am {formatDateTime(stats.closedAt!)} abgeschlossen. + + ) : openSessions.length > 0 ? ( + + + {openSessions.length} offene Bewertung(en) blockieren den Monatsabschluss. + + ) : missingCrmDays.length > 0 || crmMismatches.length > 0 ? ( + + + Der Monat ist abschließbar, hat aber noch Teamspace-Prüfpunkte. + + ) : null} + +
+ + + 0 ? "warn" : "ok"} /> + +
+ + + + Checkliste + Bewertungen sind Pflicht. Teamspace-Punkte helfen beim sauberen CRM-Abgleich. + + + {checklist.map((item) => ( +
+
+ {item.label} + 0 ? "warning" : "success"}>{item.count} +
+

{item.blocker ? "Muss erledigt sein." : "Vor Abschluss prüfen."}

+
+ ))} +
+
+ + + + Aufräumen + Alles, was für diesen Monat noch Aufmerksamkeit braucht. + + +
+
+ + Offene Bewertungen +
+ {openSessions.map((session) => ( + + ))} + {openSessions.length === 0 ?

Keine offenen Bewertungen.

: null} +
+ +
+
+ + Tage ohne Teamspace-Wert +
+ {missingCrmDays.map((item) => ( + + ))} + {missingCrmDays.length === 0 ?

Alle getrackten Tage haben einen Teamspace-Wert.

: null} +
+ +
+
+ + Teamspace-Differenzen +
+ {crmMismatches.map((item) => ( + + ))} + {crmMismatches.length === 0 ?

Keine Abweichungen zwischen Sessions und Teamspace.

: null} +
+
+
+ + {stats && stats.totals.sessions === 0 ? ( + + + +
+

Keine Sessions in diesem Monat

+

Ein Monatsabschluss ist erst sinnvoll, wenn Sessions vorhanden sind.

+
+
+
+ ) : null} +
+ ); +} diff --git a/frontend/src/views/StatisticsPage.tsx b/frontend/src/views/StatisticsPage.tsx index 1e43d8b..e49cc9f 100644 --- a/frontend/src/views/StatisticsPage.tsx +++ b/frontend/src/views/StatisticsPage.tsx @@ -2,16 +2,13 @@ import { ArrowDownRight, ArrowUpRight, Building2, - CheckCircle2, ChevronLeft, ChevronRight, CircleAlert, Clock3, ExternalLink, - FileWarning, ListChecks, RotateCcw, - Sparkles, Ticket, TrendingUp, } from "lucide-react"; @@ -20,16 +17,16 @@ import { useEffect, useMemo, useRef, useState } from "react"; import type { ReactNode } 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 { Pagination, PaginationContent, PaginationItem, PaginationNext, PaginationPrevious } from "@/components/ui/pagination"; 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"; +import { currentMonth, formatDate, formatMinutes } from "../format"; +import type { StatisticsDailyBucket, StatisticsGroup, StatisticsOverview, StatisticsTicket, WorkType } from "../types"; type StatisticsPageProps = { onNavigate: (to: string) => void; @@ -41,6 +38,9 @@ const trackedFillClass = "fill-sky-500/70 dark:fill-sky-400/65"; const teamspaceStrokeClass = "text-emerald-600 dark:text-emerald-300"; const trackedDotClass = "bg-sky-500 dark:bg-sky-400"; const teamspaceDotClass = "bg-emerald-500 dark:bg-emerald-400"; +const evaluatedTextClass = "text-violet-700 dark:text-violet-300"; +const openTextClass = "text-amber-700 dark:text-amber-300"; +const organizationPageSize = 8; function workTypeLabel(workType: WorkType | undefined) { if (workType === "support") { @@ -62,6 +62,32 @@ function percentage(value: number, total: number) { return Math.max(0, Math.min(100, Math.round((value / total) * 100))); } +function roundedPercentages(values: number[]) { + const total = values.reduce((sum, value) => sum + value, 0); + + if (total <= 0) { + return values.map(() => 0); + } + + const raw = values.map((value) => (value / total) * 100); + const floors = raw.map(Math.floor); + let remainder = 100 - floors.reduce((sum, value) => sum + value, 0); + const order = raw + .map((value, index) => ({ index, fraction: value - Math.floor(value) })) + .sort((left, right) => right.fraction - left.fraction); + + for (const item of order) { + if (remainder <= 0) { + break; + } + + floors[item.index] += 1; + remainder -= 1; + } + + return floors; +} + function formatSignedMinutes(minutes: number) { if (minutes === 0) { return "ausgeglichen"; @@ -86,6 +112,18 @@ function DeltaBadge({ minutes }: { minutes: number }) { ); } +function deltaDescription(minutes: number) { + if (minutes > 0) { + return "Teamspace liegt über den Sessions"; + } + + if (minutes < 0) { + return "Teamspace liegt unter den Sessions"; + } + + return "Teamspace und Sessions sind ausgeglichen"; +} + function TimeComparison({ trackedMinutes, crmMinutes, sessions }: { trackedMinutes: number; crmMinutes: number; sessions?: number }) { return (
@@ -96,6 +134,42 @@ function TimeComparison({ trackedMinutes, crmMinutes, sessions }: { trackedMinut ); } +function teamspaceTrend(buckets: StatisticsDailyBucket[]) { + const activeBuckets = buckets + .filter((bucket) => bucket.total_minutes > 0 || bucket.crm_billed_minutes > 0) + .map((bucket) => ({ + day: bucket.day, + deltaMinutes: bucket.crm_billed_minutes - bucket.total_minutes + })); + + if (activeBuckets.length < 2) { + return { + label: "Trend noch offen", + detail: "Zu wenige Tageswerte", + changeMinutes: 0, + firstAverage: 0, + secondAverage: 0 + }; + } + + const splitIndex = Math.ceil(activeBuckets.length / 2); + const first = activeBuckets.slice(0, splitIndex); + const second = activeBuckets.slice(splitIndex); + const average = (items: typeof activeBuckets) => Math.round(items.reduce((sum, item) => sum + item.deltaMinutes, 0) / Math.max(items.length, 1)); + const firstAverage = average(first); + const secondAverage = average(second.length > 0 ? second : first); + const changeMinutes = secondAverage - firstAverage; + const label = Math.abs(changeMinutes) < 15 ? "Trend stabil" : changeMinutes > 0 ? "Teamspace steigt relativ" : "Teamspace faellt relativ"; + + return { + label, + detail: `${formatTeamspaceDelta(firstAverage)} zu ${formatTeamspaceDelta(secondAverage)}`, + changeMinutes, + firstAverage, + secondAverage + }; +} + function formatAxisMinutes(value: number) { const minutes = Math.round(value); @@ -254,14 +328,16 @@ function MetricCard({

{label}

{value}

- {detail ?

{detail}

: null} + {detail ?
{detail}
: null}
); } -function OrganizationRow({ organization, maxMinutes }: { organization: StatisticsGroup; maxMinutes: number }) { +function OrganizationRow({ organization, maxMinutes, totalMinutes }: { organization: StatisticsGroup; maxMinutes: number; totalMinutes: number }) { + const monthlyShare = percentage(organization.total_minutes, totalMinutes); + return ( @@ -279,6 +355,7 @@ function OrganizationRow({ organization, maxMinutes }: { organization: Statistic +
{monthlyShare}% vom Monat
); @@ -309,33 +386,11 @@ function TicketHotspot({ ticket, month, onNavigate }: { ticket: StatisticsTicket ); } -function CrmDayItem({ item, onNavigate }: { item: StatisticsCrmDay; onNavigate: (to: string) => void }) { - return ( -
-
-
- {item.ticket_number} - {formatDate(`${item.day}T00:00:00`)} -
-

{item.organization_name}

-

- Sessions {formatMinutes(item.tracked_minutes)} - Teamspace {formatMinutes(item.crm_billed_minutes)} - {typeof item.delta_minutes === "number" ? Differenz {formatTeamspaceDelta(item.delta_minutes)} : null} -

-
- -
- ); -} - export function StatisticsPage({ onNavigate }: StatisticsPageProps) { const [month, setMonth] = useState(currentMonth()); const [stats, setStats] = useState(null); const [loading, setLoading] = useState(false); + const [organizationPage, setOrganizationPage] = useState(1); const loadRequestId = useRef(0); async function load() { @@ -392,18 +447,31 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) { } const totals = stats?.totals; - const billableShare = percentage(totals?.billedMinutes ?? 0, totals?.minutes ?? 0); - const openShare = percentage(totals?.openMinutes ?? 0, totals?.minutes ?? 0); + const evaluatedSessions = (totals?.billedSessions ?? 0) + (totals?.nonBillableSessions ?? 0); + const evaluatedMinutes = (totals?.billedMinutes ?? 0) + (totals?.nonBillableMinutes ?? 0); + const [evaluatedSessionShare, openSessionShare] = roundedPercentages([evaluatedSessions, totals?.openSessions ?? 0]); + const [evaluatedTimeShare, openTimeShare] = roundedPercentages([evaluatedMinutes, totals?.openMinutes ?? 0]); const 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; + 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); + const trend = useMemo(() => teamspaceTrend(stats?.dailySeries ?? []), [stats?.dailySeries]); + const organizationPageCount = Math.max(1, Math.ceil((stats?.organizations.length ?? 0) / organizationPageSize)); + const safeOrganizationPage = Math.min(organizationPage, organizationPageCount); + const organizationStart = (safeOrganizationPage - 1) * organizationPageSize; + const visibleOrganizations = (stats?.organizations ?? []).slice(organizationStart, organizationStart + organizationPageSize); + const organizationRangeStart = stats?.organizations.length ? organizationStart + 1 : 0; + const organizationRangeEnd = Math.min(organizationStart + organizationPageSize, stats?.organizations.length ?? 0); + + useEffect(() => { + setOrganizationPage(1); + }, [month]); + + useEffect(() => { + setOrganizationPage((current) => Math.min(current, organizationPageCount)); + }, [organizationPageCount]); return (
@@ -429,13 +497,6 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
- {stats?.closed ? ( - - - Dieser Monat wurde am {formatDateTime(stats.closedAt!)} abgeschlossen. - - ) : null} -
+
{evaluatedSessions} von {totals?.sessions ?? 0} Session(s), {evaluatedSessionShare}%
+
{evaluatedTimeShare}% der Zeit · Abr. {totals?.billedSessions ?? 0} · Nicht {totals?.nonBillableSessions ?? 0}
+
+ } icon={ListChecks} + valueClassName={evaluatedTextClass} /> +
{totals?.openSessions ?? 0} von {totals?.sessions ?? 0} Session(s), {openSessionShare}%
+
{openTimeShare}% der Zeit
+
+ } icon={CircleAlert} + valueClassName={openTextClass} />
@@ -493,23 +566,48 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) { Abrechnungsqualität - Bewertung, CRM-Abdeckung und Erfassungsart. + Bewertungsstand, Teamspace-Abgleich und Entwicklung im Monat. -
-
- Abgerechnet - {billableShare}% +
+
+ Bewertungsstand + {evaluatedSessionShare}% / {openSessionShare}%
- -
-
-
- Teamspace - {crmCoverage}% +
+
+
+
+
+
Bewertet: {evaluatedSessions} Session(s), {formatMinutes(evaluatedMinutes)}
+
Offen: {totals?.openSessions ?? 0} Session(s), {formatMinutes(totals?.openMinutes ?? 0)}
-
+ +
+
+
+

Teamspace-Abgleich

+

{deltaDescription(totals?.crmDeltaMinutes ?? 0)}

+
+ +
+ +
Teamspace-Abdeckung: {crmCoverage}% der getrackten Session-Zeit
+
+ +
+
+
+

{trend.label}

+

{trend.detail}

+
+ + {formatSignedMinutes(trend.changeMinutes)} + +
+
+

Manuell

@@ -546,9 +644,14 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
- - Statistik pro Organisation - Kundenaufwand inklusive Teamspace-Differenz. + +
+ Statistik pro Organisation + Kundenaufwand inklusive Teamspace-Differenz und Anteil am getrackten Monatsaufwand. +
+ + {organizationRangeStart}-{organizationRangeEnd} von {stats?.organizations.length ?? 0} +
@@ -559,18 +662,23 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) { Sessions Zeiten TS-Differenz - Anteil + Anteil am Monat - {(stats?.organizations ?? []).map((organization) => ( - + {visibleOrganizations.map((organization) => ( + ))}
- {(stats?.organizations ?? []).map((organization) => ( + {visibleOrganizations.map((organization) => (
@@ -583,11 +691,47 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
Sessions {formatMinutes(organization.total_minutes)} Teamspace {formatMinutes(organization.crm_billed_minutes)} + {percentage(organization.total_minutes, totals?.minutes ?? 0)}% vom Monat {formatTeamspaceDelta(organization.crm_delta_minutes)}
))}
+ {(stats?.organizations.length ?? 0) > organizationPageSize ? ( + + + + { + event.preventDefault(); + setOrganizationPage((current) => Math.max(1, current - 1)); + }} + /> + + + + Seite {safeOrganizationPage} von {organizationPageCount} + + + + = organizationPageCount} + className={safeOrganizationPage >= organizationPageCount ? "pointer-events-none opacity-50" : undefined} + onClick={(event) => { + event.preventDefault(); + setOrganizationPage((current) => Math.min(organizationPageCount, current + 1)); + }} + /> + + + + ) : null} {stats && stats.organizations.length === 0 ?

Keine Organisationen im gewählten Monat.

: null} @@ -624,80 +768,24 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
-
- - -
- 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} -
-
-
-
+ + +
+ 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} +
+