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)}
/>
changePeriodBy(1)} aria-label={`${periodLabel} vor`}>
@@ -643,73 +597,14 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
- {supportsPeriodClosure && overview?.closed ? (
-
-
- Dieser Monat wurde am {formatDateTime(overview.closedAt!)} abgeschlossen.
-
- ) : null}
-
- {overview && overview.openSessions.length > 0 ? (
-
-
-
- Es gibt noch {overview.openSessions.length} Session(s) ohne Auswahl.
-
- setShowOpen((value) => !value)}>
- Offene Sessions prüfen
-
-
- ) : 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}
-
-
onNavigate(`/analysis/${periodType}/${period}/tickets/${session.ticket_id}`)}>
- Öffnen
-
-
-
- ))}
-
-
- ) : 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 ? (
-
-
- {reopening ? "Öffnet..." : "Monat wieder öffnen"}
-
- ) : (
-
-
- {closing ? "Schließt..." : overview?.canClose ? "Monat abschließen" : "Noch nicht abschließbar"}
-
- )
- ) : 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}
+
+
onNavigate(`/analysis/month/${month}/tickets/${session.ticket_id}`)}>
+ Bewerten
+
+
+
+ );
+}
+
+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}
+
+
+
onNavigate(`/analysis/day/${item.day}/tickets/${item.ticket_id}`)}>
+ Tag öffnen
+
+
+
+ );
+}
+
+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.
+
+
+
+
+ Monat
+ {loading ? lädt... : null}
+
+
+ changeMonthBy(-1)} aria-label="Monat zurück">
+
+
+ setMonth(event.currentTarget.value)} />
+ changeMonthBy(1)} aria-label="Monat vor">
+
+
+
+
+ {stats?.closed ? (
+
+
+ {reopening ? "Öffnet..." : "Monat öffnen"}
+
+ ) : (
+
+
+ {closing ? "Schließt..." : "Monat abschließen"}
+
+ )}
+
+
+
+ {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}
-
-
-
onNavigate(`/analysis/day/${item.day}/tickets/${item.ticket_id}`)}>
- Tag öffnen
-
-
-
- );
-}
-
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.
-
- onNavigate("/analysis")}>
- Auswertung öffnen
-
-
-
-
- {(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}
-
-
onNavigate(`/analysis/month/${month}/tickets/${session.ticket_id}`)}>
- Öffnen
-
-
-
- ))}
- {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.
+
+ onNavigate("/analysis")}>
+ Auswertung öffnen
+
+
+
+
+ {(stats?.tickets ?? []).map((ticket) => (
+
+ ))}
+ {stats && stats.tickets.length === 0 ? Keine Tickets im gewählten Monat.
: null}
+
+