Add monthly statistics dashboard

This commit is contained in:
2026-08-05 14:25:08 +02:00
parent cfc4ef45a8
commit eafd718e64
5 changed files with 1091 additions and 0 deletions
+329
View File
@@ -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) { async function getPeriodTicket(config: PeriodConfig, period: ParsedPeriod, ticketId: number, userId: string) {
await ensureRecurringSessionsForUserPeriod(userId, period.startIso, period.endIso); 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)); 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) => { app.get("/api/tickets/lookup", async (req, res) => {
const ticketNumber = parseTicketNumber(req.query.ticketNumber); const ticketNumber = parseTicketNumber(req.query.ticketNumber);
const result = await query( const result = await query(
+8
View File
@@ -2,6 +2,7 @@ import {
BarChart3, BarChart3,
CheckCircle2, CheckCircle2,
Command, Command,
ChartNoAxesCombined,
LayoutGrid, LayoutGrid,
LogOut, LogOut,
Moon, Moon,
@@ -57,6 +58,7 @@ import { AnalysisPage } from "./views/AnalysisPage";
import { LoginPage } from "./views/LoginPage"; import { LoginPage } from "./views/LoginPage";
import { ProfilePage } from "./views/ProfilePage"; import { ProfilePage } from "./views/ProfilePage";
import { RecurringBillingsPage } from "./views/RecurringBillingsPage"; import { RecurringBillingsPage } from "./views/RecurringBillingsPage";
import { StatisticsPage } from "./views/StatisticsPage";
import { TicketDetailPage } from "./views/TicketDetailPage"; import { TicketDetailPage } from "./views/TicketDetailPage";
import { TimerPage } from "./views/TimerPage"; import { TimerPage } from "./views/TimerPage";
import type { AuthUser, PeriodType } from "./types"; import type { AuthUser, PeriodType } from "./types";
@@ -89,6 +91,10 @@ function routeFromPath(pathname: string) {
return { page: "analysis" as const }; return { page: "analysis" as const };
} }
if (pathname.startsWith("/statistics")) {
return { page: "statistics" as const };
}
if (pathname.startsWith("/recurring")) { if (pathname.startsWith("/recurring")) {
return { page: "recurring" as const }; return { page: "recurring" as const };
} }
@@ -501,6 +507,7 @@ export function App() {
const navItems = [ const navItems = [
{ href: "/timer", label: "Timer", icon: Timer, active: path.startsWith("/timer") || path === "/" }, { href: "/timer", label: "Timer", icon: Timer, active: path.startsWith("/timer") || path === "/" },
{ href: "/analysis", label: "Auswertung", icon: BarChart3, active: path.startsWith("/analysis") }, { 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: "/recurring", label: "Fixe Abrechnung", icon: Repeat, active: path.startsWith("/recurring") },
{ href: "/profile", label: "Profil", icon: UserCog, active: path.startsWith("/profile") }, { href: "/profile", label: "Profil", icon: UserCog, active: path.startsWith("/profile") },
...(currentUser.role === "admin" ...(currentUser.role === "admin"
@@ -685,6 +692,7 @@ export function App() {
/> />
) : null} ) : null}
{route.page === "analysis" ? <AnalysisPage onNavigate={navigate} /> : null} {route.page === "analysis" ? <AnalysisPage onNavigate={navigate} /> : null}
{route.page === "statistics" ? <StatisticsPage onNavigate={navigate} /> : null}
{route.page === "recurring" ? <RecurringBillingsPage /> : null} {route.page === "recurring" ? <RecurringBillingsPage /> : null}
{route.page === "profile" ? <ProfilePage currentUser={currentUser} onUserUpdated={setCurrentUser} /> : null} {route.page === "profile" ? <ProfilePage currentUser={currentUser} onUserUpdated={setCurrentUser} /> : null}
{route.page === "admin-users" && currentUser.role === "admin" ? <AdminUsersPage currentUser={currentUser} /> : null} {route.page === "admin-users" && currentUser.role === "admin" ? <AdminUsersPage currentUser={currentUser} /> : null}
+5
View File
@@ -7,6 +7,7 @@ import type {
PeriodOverview, PeriodOverview,
PeriodType, PeriodType,
RecurringBilling, RecurringBilling,
StatisticsOverview,
TicketMeta, TicketMeta,
TicketPeriod, TicketPeriod,
UserRole, UserRole,
@@ -299,6 +300,10 @@ export function getPeriodOverview(type: PeriodType, period: string) {
return request<PeriodOverview>(`/api/periods/${periodPath(type)}/${period}/overview`); return request<PeriodOverview>(`/api/periods/${periodPath(type)}/${period}/overview`);
} }
export function getStatisticsOverview(month: string) {
return request<StatisticsOverview>(`/api/statistics/months/${month}`);
}
export function getTicketPeriod(type: PeriodType, period: string, ticketId: string) { export function getTicketPeriod(type: PeriodType, period: string, ticketId: string) {
return request<TicketPeriod>(`/api/periods/${periodPath(type)}/${period}/tickets/${ticketId}`); return request<TicketPeriod>(`/api/periods/${periodPath(type)}/${period}/tickets/${ticketId}`);
} }
+91
View File
@@ -191,3 +191,94 @@ export type TicketPeriod = {
export type MonthOverview = PeriodOverview; export type MonthOverview = PeriodOverview;
export type TicketMonth = TicketPeriod; 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[];
};
};
+658
View File
@@ -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 (
<div className="h-56 w-full overflow-hidden rounded-md border bg-background">
{series.some((bucket) => bucket.totalMinutes > 0 || bucket.crmBilledMinutes > 0) ? (
<svg viewBox={`0 0 ${width} ${height}`} className="h-full w-full">
{ticks.map((tick) => {
const y = padding.top + (1 - tick / maxMinutes) * innerHeight;
return (
<g key={tick}>
<line x1={padding.left} x2={width - padding.right} y1={y} y2={y} stroke="currentColor" className="text-muted/70" />
<text x={padding.left - 9} y={y + 4} textAnchor="end" className="fill-muted-foreground text-[11px]">
{formatAxisMinutes(tick)}
</text>
</g>
);
})}
<line x1={padding.left} x2={padding.left} y1={padding.top} y2={padding.top + innerHeight} stroke="currentColor" className="text-muted-foreground/70" />
{points.map((point) => {
const y = padding.top + innerHeight - point.totalBarHeight;
return point.totalBarHeight > 0 ? (
<rect key={point.bucket.day} x={point.x - barWidth / 2} y={y} width={barWidth} height={point.totalBarHeight} rx="4" className="fill-neutral-300 dark:fill-neutral-700" />
) : null;
})}
{crmPath ? <path d={crmPath} fill="none" stroke="currentColor" strokeWidth="2.5" className="text-foreground" /> : null}
{points
.filter((point) => point.bucket.crmBilledMinutes > 0)
.map((point) => (
<circle key={`${point.bucket.day}-crm`} cx={point.x} cy={point.crmY} r="3" className="fill-background stroke-foreground" strokeWidth="2" />
))}
{points.map((point, index) =>
index % labelStep === 0 || index === points.length - 1 ? (
<text key={`${point.bucket.day}-label`} x={point.x} y={height - 11} textAnchor="middle" className="fill-muted-foreground text-[11px]">
{point.bucket.label}
</text>
) : null
)}
</svg>
) : (
<div className="grid h-full place-items-center text-sm text-muted-foreground">Keine Statistikdaten für diesen Monat.</div>
)}
</div>
);
}
function MetricCard({ label, value, detail, icon: Icon }: { label: string; value: string | number; detail?: string; icon: LucideIcon }) {
return (
<Card>
<CardContent className="flex items-center gap-3 p-3">
<div className="grid size-8 shrink-0 place-items-center rounded-lg border bg-muted/40 text-muted-foreground">
<Icon className="size-3.5" />
</div>
<div className="min-w-0">
<p className="text-xs font-medium text-muted-foreground">{label}</p>
<p className="truncate text-xl font-semibold tracking-normal">{value}</p>
{detail ? <p className="truncate text-xs text-muted-foreground">{detail}</p> : null}
</div>
</CardContent>
</Card>
);
}
function OrganizationRow({ organization, maxMinutes }: { organization: StatisticsGroup; maxMinutes: number }) {
return (
<TableRow>
<TableCell className="max-w-[320px] whitespace-normal">
<div className="font-medium">{organization.organization_name ?? "Keine Organisation"}</div>
<div className="text-xs text-muted-foreground">{organization.tickets} Ticket(s) · {organization.sessions} Session(s)</div>
</TableCell>
<TableCell>{formatMinutes(organization.total_minutes)}</TableCell>
<TableCell>{formatMinutes(organization.crm_billed_minutes)}</TableCell>
<TableCell>
<Badge variant={organization.crm_delta_minutes === 0 ? "success" : "warning"}>{formatSignedMinutes(organization.crm_delta_minutes)}</Badge>
</TableCell>
<TableCell className="min-w-[150px]">
<Progress value={percentage(organization.total_minutes, maxMinutes)} />
</TableCell>
</TableRow>
);
}
function TicketHotspot({ ticket, month, onNavigate }: { ticket: StatisticsTicket; month: string; onNavigate: (to: string) => void }) {
return (
<div className="grid gap-2 rounded-md border bg-background p-3 sm:grid-cols-[1fr_auto] sm:items-center">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="font-semibold">{ticket.ticket_number}</span>
{ticket.open_sessions > 0 ? <Badge variant="warning">{ticket.open_sessions} offen</Badge> : <Badge variant="success">bewertet</Badge>}
</div>
<p className="truncate text-sm text-muted-foreground">{ticket.organization_name ?? "Keine Organisation"}</p>
<div className="mt-1 flex flex-wrap gap-2 text-xs text-muted-foreground">
<span>{formatMinutes(ticket.total_minutes)} getrackt</span>
<span>Teamspace {formatMinutes(ticket.crm_billed_minutes)}</span>
<span>{ticket.active_days} Tag(e)</span>
</div>
</div>
<Button variant="secondary" size="sm" onClick={() => onNavigate(`/analysis/month/${month}/tickets/${ticket.ticket_id}`)}>
Öffnen
<ExternalLink className="size-4" />
</Button>
</div>
);
}
function CrmDayItem({ item, onNavigate }: { item: StatisticsCrmDay; onNavigate: (to: string) => void }) {
return (
<div className="grid gap-2 rounded-md border bg-background p-3 sm:grid-cols-[1fr_auto] sm:items-center">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{item.ticket_number}</span>
<Badge variant="outline">{formatDate(`${item.day}T00:00:00`)}</Badge>
</div>
<p className="truncate text-sm text-muted-foreground">{item.organization_name}</p>
<p className="text-xs text-muted-foreground">
Getrackt {formatMinutes(item.tracked_minutes)} · Teamspace {formatMinutes(item.crm_billed_minutes)}
{typeof item.delta_minutes === "number" ? ` · Differenz ${formatSignedMinutes(item.delta_minutes)}` : ""}
</p>
</div>
<Button variant="ghost" size="sm" onClick={() => onNavigate(`/analysis/day/${item.day}/tickets/${item.ticket_id}`)}>
Tag öffnen
<ExternalLink className="size-4" />
</Button>
</div>
);
}
export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
const [month, setMonth] = useState(currentMonth());
const [stats, setStats] = useState<StatisticsOverview | null>(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 (
<div className="space-y-5">
<div className="flex flex-col gap-3 xl:flex-row xl:items-end xl:justify-between">
<div>
<h2 className="text-2xl font-semibold tracking-normal">Statistiken</h2>
<p className="text-sm text-muted-foreground">Monatswerte, Teamspace-Abgleich, Organisationen und offene Nacharbeit.</p>
</div>
<div className="grid gap-1 sm:w-[300px]">
<div className="flex items-center justify-between gap-2">
<label className="text-xs font-medium text-muted-foreground" htmlFor="statistics-month">Monat</label>
{loading ? <span className="text-xs text-muted-foreground">lädt...</span> : null}
</div>
<div className="grid grid-cols-[2.25rem_minmax(0,1fr)_2.25rem] gap-1">
<Button type="button" size="icon" variant="secondary" className="h-9 w-9" onClick={() => changeMonthBy(-1)} aria-label="Monat zurück">
<ChevronLeft className="size-4" />
</Button>
<Input id="statistics-month" className="h-9" type="month" value={month} onChange={(event) => setMonth(event.currentTarget.value)} />
<Button type="button" size="icon" variant="secondary" className="h-9 w-9" onClick={() => changeMonthBy(1)} aria-label="Monat vor">
<ChevronRight className="size-4" />
</Button>
</div>
</div>
</div>
{stats?.closed ? (
<Alert variant="success" className="flex items-center gap-2 py-3">
<CheckCircle2 className="size-4 shrink-0" />
<span>Dieser Monat wurde am {formatDateTime(stats.closedAt!)} abgeschlossen.</span>
</Alert>
) : null}
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<MetricCard label="Getrackt" value={formatMinutes(totals?.minutes ?? 0)} detail={`${totals?.sessions ?? 0} Session(s), ${totals?.tickets ?? 0} Ticket(s)`} icon={Clock3} />
<MetricCard label="Teamspace" value={formatMinutes(totals?.crmBilledMinutes ?? 0)} detail={`Differenz ${formatSignedMinutes(totals?.crmDeltaMinutes ?? 0)}`} icon={TrendingUp} />
<MetricCard label="Abgerechnet" value={formatMinutes(totals?.billedMinutes ?? 0)} detail={`${billableShare}% der getrackten Zeit`} icon={ListChecks} />
<MetricCard label="Offen" value={formatMinutes(totals?.openMinutes ?? 0)} detail={`${totals?.openSessions ?? 0} Session(s), ${openShare}%`} icon={CircleAlert} />
</div>
<div className="grid gap-3 lg:grid-cols-[1.6fr_1fr]">
<Card>
<CardHeader className="gap-3 p-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
<div>
<CardTitle>Monatsverlauf</CardTitle>
<CardDescription>Getrackter Aufwand pro Tag mit Teamspace-Linie.</CardDescription>
</div>
<Badge variant="outline">{crmCoverage}% Teamspace-Abdeckung</Badge>
</CardHeader>
<CardContent className="px-4 pb-4">
<DailyEffortChart month={month} buckets={stats?.dailySeries ?? []} />
<div className="mt-3 flex flex-wrap gap-3 text-xs text-muted-foreground">
<span className="inline-flex items-center gap-1.5">
<span className="size-2 rounded-sm bg-neutral-300 dark:bg-neutral-700" />
Getrackt
</span>
<span className="inline-flex items-center gap-1.5">
<span className="h-0.5 w-4 bg-foreground" />
Teamspace
</span>
{strongestDay ? <span>Stärkster Tag: {formatDate(`${strongestDay.day}T00:00:00`)} mit {formatMinutes(strongestDay.total_minutes)}</span> : null}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="p-4">
<CardTitle>Abrechnungsqualität</CardTitle>
<CardDescription>Bewertung, CRM-Abdeckung und Erfassungsart.</CardDescription>
</CardHeader>
<CardContent className="space-y-4 px-4 pb-4">
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Abgerechnet</span>
<span className="font-medium">{billableShare}%</span>
</div>
<Progress value={billableShare} />
</div>
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Teamspace</span>
<span className="font-medium">{crmCoverage}%</span>
</div>
<Progress value={crmCoverage} />
</div>
<div className="grid grid-cols-2 gap-2 text-sm">
<div className="rounded-md border bg-muted/20 p-3">
<p className="text-muted-foreground">Manuell</p>
<p className="font-semibold">{formatMinutes(totals?.manualMinutes ?? 0)}</p>
</div>
<div className="rounded-md border bg-muted/20 p-3">
<p className="text-muted-foreground">Fix</p>
<p className="font-semibold">{formatMinutes(totals?.recurringMinutes ?? 0)}</p>
</div>
<div className="rounded-md border bg-muted/20 p-3">
<p className="text-muted-foreground">Aktive Tage</p>
<p className="font-semibold">{totals?.activeDays ?? 0}</p>
</div>
<div className="rounded-md border bg-muted/20 p-3">
<p className="text-muted-foreground">Schnitt</p>
<p className="font-semibold">{formatMinutes(totals?.averageSessionMinutes ?? 0)}</p>
</div>
</div>
{topOrganization ? (
<div className="rounded-md border bg-muted/20 p-3 text-sm">
<p className="text-muted-foreground">Top Organisation</p>
<p className="truncate font-semibold">{topOrganization.organization_name}</p>
<p className="text-xs text-muted-foreground">{formatMinutes(topOrganization.total_minutes)}</p>
</div>
) : null}
</CardContent>
</Card>
</div>
<div className="grid gap-3 xl:grid-cols-[1.45fr_0.8fr]">
<Card>
<CardHeader className="p-4">
<CardTitle>Statistik pro Organisation</CardTitle>
<CardDescription>Kundenaufwand inklusive Teamspace-Differenz.</CardDescription>
</CardHeader>
<CardContent className="px-4 pb-4">
<div className="hidden md:block">
<Table>
<TableHeader>
<TableRow>
<TableHead>Organisation</TableHead>
<TableHead>Zeit</TableHead>
<TableHead>Teamspace</TableHead>
<TableHead>Differenz</TableHead>
<TableHead>Anteil</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(stats?.organizations ?? []).map((organization) => (
<OrganizationRow key={organization.organization_id ?? organization.organization_name} organization={organization} maxMinutes={maxOrganizationMinutes} />
))}
</TableBody>
</Table>
</div>
<div className="space-y-2 md:hidden">
{(stats?.organizations ?? []).map((organization) => (
<div key={organization.organization_id ?? organization.organization_name} className="space-y-2 rounded-md border bg-background p-3">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate font-medium">{organization.organization_name ?? "Keine Organisation"}</p>
<p className="text-xs text-muted-foreground">{organization.tickets} Ticket(s) · {organization.sessions} Session(s)</p>
</div>
<Badge variant={organization.crm_delta_minutes === 0 ? "success" : "warning"}>{formatSignedMinutes(organization.crm_delta_minutes)}</Badge>
</div>
<Progress value={percentage(organization.total_minutes, maxOrganizationMinutes)} />
<p className="text-xs text-muted-foreground">{formatMinutes(organization.total_minutes)} getrackt · Teamspace {formatMinutes(organization.crm_billed_minutes)}</p>
</div>
))}
</div>
{stats && stats.organizations.length === 0 ? <p className="py-8 text-center text-sm text-muted-foreground">Keine Organisationen im gewählten Monat.</p> : null}
</CardContent>
</Card>
<Card>
<CardHeader className="p-4">
<CardTitle>Typen</CardTitle>
<CardDescription>Support und Consulting im direkten Vergleich.</CardDescription>
</CardHeader>
<CardContent className="space-y-3 px-4 pb-4">
{(stats?.workTypes ?? []).map((group) => (
<div key={group.work_type ?? "none"} className="space-y-2 rounded-md border bg-background p-3">
<div className="flex items-center justify-between gap-3">
<div>
<p className="font-semibold">{workTypeLabel(group.work_type)}</p>
<p className="text-xs text-muted-foreground">{group.tickets} Ticket(s), {group.sessions} Session(s)</p>
</div>
<Badge variant="outline">{formatMinutes(group.total_minutes)}</Badge>
</div>
<Progress value={percentage(group.total_minutes, totals?.minutes ?? 0)} />
<div className="grid grid-cols-3 gap-2 text-xs text-muted-foreground">
<span>Abr. {formatMinutes(group.billed_minutes)}</span>
<span>Nicht {formatMinutes(group.non_billable_minutes)}</span>
<span>Offen {formatMinutes(group.open_minutes)}</span>
</div>
</div>
))}
{stats && stats.workTypes.length === 0 ? <p className="py-8 text-center text-sm text-muted-foreground">Noch keine Typdaten vorhanden.</p> : null}
</CardContent>
</Card>
</div>
<div className="grid gap-3 xl:grid-cols-2">
<Card>
<CardHeader className="gap-3 p-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
<div>
<CardTitle>Ticket-Hotspots</CardTitle>
<CardDescription>Die größten Zeitblöcke im Monat.</CardDescription>
</div>
<Button variant="secondary" size="sm" onClick={() => onNavigate("/analysis")}>
Auswertung öffnen
<ExternalLink className="size-4" />
</Button>
</CardHeader>
<CardContent className="space-y-2 px-4 pb-4">
{(stats?.tickets ?? []).map((ticket) => (
<TicketHotspot key={ticket.ticket_id} ticket={ticket} month={month} onNavigate={onNavigate} />
))}
{stats && stats.tickets.length === 0 ? <p className="py-8 text-center text-sm text-muted-foreground">Keine Tickets im gewählten Monat.</p> : null}
</CardContent>
</Card>
<Card>
<CardHeader className="gap-3 p-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
<div>
<CardTitle>Aufräumen</CardTitle>
<CardDescription>Alles, was vor dem Monatsabschluss Aufmerksamkeit braucht.</CardDescription>
</div>
<Badge variant={attentionCount > 0 ? "warning" : "success"}>{attentionCount} Punkt(e)</Badge>
</CardHeader>
<CardContent className="space-y-4 px-4 pb-4">
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm font-medium">
<FileWarning className="size-4 text-muted-foreground" />
Offene Bewertungen
</div>
{(stats?.attention.openSessions ?? []).slice(0, 5).map((session) => (
<div key={session.id} className="grid gap-2 rounded-md border bg-background p-3 sm:grid-cols-[1fr_auto] sm:items-center">
<div className="min-w-0">
<p className="truncate font-medium">{session.ticket_number}</p>
<p className="truncate text-sm text-muted-foreground">{session.organization_name}</p>
<p className="truncate text-xs text-muted-foreground">{session.activity}</p>
</div>
<Button variant="ghost" size="sm" onClick={() => onNavigate(`/analysis/month/${month}/tickets/${session.ticket_id}`)}>
Öffnen
<ExternalLink className="size-4" />
</Button>
</div>
))}
{stats?.attention.openSessions.length === 0 ? <p className="rounded-md border bg-muted/20 p-3 text-sm text-muted-foreground">Keine offenen Bewertungen.</p> : null}
</div>
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm font-medium">
<Sparkles className="size-4 text-muted-foreground" />
Tage ohne Teamspace-Wert
</div>
{(stats?.attention.missingCrmDays ?? []).slice(0, 4).map((item) => (
<CrmDayItem key={`${item.ticket_id}-${item.day}-missing`} item={item} onNavigate={onNavigate} />
))}
{stats?.attention.missingCrmDays.length === 0 ? <p className="rounded-md border bg-muted/20 p-3 text-sm text-muted-foreground">Alle getrackten Tage haben einen Teamspace-Wert.</p> : null}
</div>
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm font-medium">
<ArrowUpRight className="size-4 text-muted-foreground" />
Auffällige Differenzen
</div>
{(stats?.attention.crmMismatches ?? []).slice(0, 4).map((item) => (
<CrmDayItem key={`${item.ticket_id}-${item.day}-mismatch`} item={item} onNavigate={onNavigate} />
))}
{stats?.attention.crmMismatches.length === 0 ? <p className="rounded-md border bg-muted/20 p-3 text-sm text-muted-foreground">Keine Abweichungen zwischen Tracking und Teamspace.</p> : null}
</div>
</CardContent>
</Card>
</div>
<div className="grid gap-3 sm:grid-cols-3">
<Card>
<CardContent className="flex items-center gap-3 p-3">
<ArrowUpRight className="size-4 text-muted-foreground" />
<div>
<p className="text-xs text-muted-foreground">Mehr getrackt als Teamspace</p>
<p className="font-semibold">{formatMinutes(Math.max(totals?.crmDeltaMinutes ?? 0, 0))}</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-3 p-3">
<ArrowDownRight className="size-4 text-muted-foreground" />
<div>
<p className="text-xs text-muted-foreground">Mehr Teamspace als getrackt</p>
<p className="font-semibold">{formatMinutes(Math.max(-(totals?.crmDeltaMinutes ?? 0), 0))}</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-3 p-3">
<Building2 className="size-4 text-muted-foreground" />
<div>
<p className="text-xs text-muted-foreground">Organisationen</p>
<p className="font-semibold">{stats?.organizations.length ?? 0}</p>
</div>
</CardContent>
</Card>
</div>
{stats && stats.totals.sessions === 0 ? (
<Card>
<CardContent className="grid place-items-center gap-2 p-8 text-center">
<Ticket className="size-8 text-muted-foreground" />
<div>
<p className="font-medium">Keine Sessions in diesem Monat</p>
<p className="text-sm text-muted-foreground">Sobald Sessions vorhanden sind, füllt sich die Statistik automatisch.</p>
</div>
<Button variant="secondary" size="sm" onClick={() => setMonth(currentMonth())}>
<RotateCcw className="size-4" />
Aktuellen Monat zeigen
</Button>
</CardContent>
</Card>
) : null}
</div>
);
}