Add monthly statistics dashboard
This commit is contained in:
@@ -659,6 +659,330 @@ async function getPeriodOverview(config: PeriodConfig, period: ParsedPeriod, use
|
||||
};
|
||||
}
|
||||
|
||||
async function getStatisticsOverview(period: ParsedPeriod, userId: string) {
|
||||
await ensureRecurringSessionsForUserPeriod(userId, period.startIso, period.endIso);
|
||||
|
||||
const baseCte = `
|
||||
WITH session_base AS (
|
||||
SELECT
|
||||
s.id,
|
||||
s.ticket_id,
|
||||
t.ticket_number,
|
||||
COALESCE(s.organization_id, t.organization_id) AS organization_id,
|
||||
COALESCE(so.name, ot.name, s.customer_name, t.customer_name, 'Keine Organisation') AS organization_name,
|
||||
s.activity,
|
||||
s.work_type,
|
||||
s.started_at,
|
||||
(s.started_at AT TIME ZONE 'Europe/Berlin')::date AS day,
|
||||
s.rounded_minutes,
|
||||
s.billing_status,
|
||||
s.recurring_billing_id
|
||||
FROM sessions s
|
||||
JOIN tickets t ON t.id = s.ticket_id
|
||||
LEFT JOIN organizations so ON so.id = s.organization_id
|
||||
LEFT JOIN organizations ot ON ot.id = t.organization_id
|
||||
WHERE s.started_at >= $1::timestamptz
|
||||
AND s.started_at < $2::timestamptz
|
||||
AND s.user_id = $3
|
||||
),
|
||||
ticket_day AS (
|
||||
SELECT
|
||||
ticket_id,
|
||||
ticket_number,
|
||||
day,
|
||||
SUM(rounded_minutes)::int AS tracked_minutes
|
||||
FROM session_base
|
||||
GROUP BY ticket_id, ticket_number, day
|
||||
),
|
||||
ticket_day_billing AS (
|
||||
SELECT
|
||||
td.ticket_id,
|
||||
td.ticket_number,
|
||||
td.day,
|
||||
td.tracked_minutes,
|
||||
COALESCE(tdb.billed_minutes, 0)::int AS crm_billed_minutes,
|
||||
tdb.billed_minutes IS NOT NULL AS has_crm_value
|
||||
FROM ticket_day td
|
||||
LEFT JOIN ticket_day_billings tdb
|
||||
ON tdb.ticket_id = td.ticket_id
|
||||
AND tdb.user_id = $3
|
||||
AND tdb.day = td.day
|
||||
),
|
||||
session_group AS (
|
||||
SELECT
|
||||
sb.organization_id,
|
||||
sb.organization_name,
|
||||
sb.work_type,
|
||||
sb.ticket_id,
|
||||
sb.ticket_number,
|
||||
sb.day,
|
||||
COUNT(*)::int AS session_count,
|
||||
SUM(sb.rounded_minutes)::int AS total_minutes,
|
||||
SUM(sb.rounded_minutes) FILTER (WHERE sb.billing_status = 'billed')::int AS billed_minutes,
|
||||
SUM(sb.rounded_minutes) FILTER (WHERE sb.billing_status = 'non_billable')::int AS non_billable_minutes,
|
||||
SUM(sb.rounded_minutes) FILTER (WHERE sb.billing_status IS NULL)::int AS open_minutes,
|
||||
COUNT(*) FILTER (WHERE sb.billing_status IS NULL)::int AS open_count,
|
||||
COUNT(*) FILTER (WHERE sb.recurring_billing_id IS NOT NULL)::int AS recurring_session_count,
|
||||
COUNT(*) FILTER (WHERE sb.recurring_billing_id IS NULL)::int AS manual_session_count
|
||||
FROM session_base sb
|
||||
GROUP BY sb.organization_id, sb.organization_name, sb.work_type, sb.ticket_id, sb.ticket_number, sb.day
|
||||
),
|
||||
session_group_with_crm AS (
|
||||
SELECT
|
||||
sg.*,
|
||||
ROUND(COALESCE(tdb.crm_billed_minutes, 0) * sg.total_minutes::numeric / NULLIF(tdb.tracked_minutes, 0))::int AS crm_billed_minutes
|
||||
FROM session_group sg
|
||||
LEFT JOIN ticket_day_billing tdb
|
||||
ON tdb.ticket_id = sg.ticket_id
|
||||
AND tdb.day = sg.day
|
||||
)
|
||||
`;
|
||||
|
||||
const [
|
||||
totalsResult,
|
||||
dailyResult,
|
||||
organizationResult,
|
||||
workTypeResult,
|
||||
ticketResult,
|
||||
openResult,
|
||||
missingCrmResult,
|
||||
mismatchResult,
|
||||
closureResult
|
||||
] = await Promise.all([
|
||||
query(
|
||||
`
|
||||
${baseCte}
|
||||
SELECT
|
||||
COUNT(DISTINCT ticket_id)::int AS tickets,
|
||||
COUNT(*)::int AS sessions,
|
||||
COALESCE(SUM(rounded_minutes), 0)::int AS minutes,
|
||||
COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status = 'billed'), 0)::int AS billed_minutes,
|
||||
COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status = 'non_billable'), 0)::int AS non_billable_minutes,
|
||||
COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status IS NULL), 0)::int AS open_minutes,
|
||||
COUNT(*) FILTER (WHERE billing_status IS NULL)::int AS open_sessions,
|
||||
COALESCE(SUM(rounded_minutes) FILTER (WHERE recurring_billing_id IS NOT NULL), 0)::int AS recurring_minutes,
|
||||
COALESCE(SUM(rounded_minutes) FILTER (WHERE recurring_billing_id IS NULL), 0)::int AS manual_minutes,
|
||||
COALESCE(ROUND(AVG(rounded_minutes)), 0)::int AS average_session_minutes,
|
||||
COUNT(DISTINCT day)::int AS active_days,
|
||||
COALESCE((SELECT SUM(crm_billed_minutes)::int FROM ticket_day_billing), 0)::int AS crm_billed_minutes
|
||||
FROM session_base;
|
||||
`,
|
||||
[period.startIso, period.endIso, userId]
|
||||
),
|
||||
query(
|
||||
`
|
||||
${baseCte}
|
||||
SELECT
|
||||
td.day::text AS day,
|
||||
COALESCE(day_sessions.session_count, 0)::int AS sessions,
|
||||
td.tracked_minutes::int AS total_minutes,
|
||||
COALESCE(day_sessions.billed_minutes, 0)::int AS billed_minutes,
|
||||
COALESCE(day_sessions.non_billable_minutes, 0)::int AS non_billable_minutes,
|
||||
COALESCE(day_sessions.open_minutes, 0)::int AS open_minutes,
|
||||
COALESCE(SUM(tdb.crm_billed_minutes), 0)::int AS crm_billed_minutes
|
||||
FROM (
|
||||
SELECT day, SUM(tracked_minutes)::int AS tracked_minutes
|
||||
FROM ticket_day
|
||||
GROUP BY day
|
||||
) td
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
day,
|
||||
SUM(session_count)::int AS session_count,
|
||||
SUM(COALESCE(billed_minutes, 0))::int AS billed_minutes,
|
||||
SUM(COALESCE(non_billable_minutes, 0))::int AS non_billable_minutes,
|
||||
SUM(COALESCE(open_minutes, 0))::int AS open_minutes
|
||||
FROM session_group
|
||||
GROUP BY day
|
||||
) day_sessions ON day_sessions.day = td.day
|
||||
LEFT JOIN ticket_day_billing tdb ON tdb.day = td.day
|
||||
GROUP BY td.day, td.tracked_minutes, day_sessions.session_count, day_sessions.billed_minutes, day_sessions.non_billable_minutes, day_sessions.open_minutes
|
||||
ORDER BY td.day ASC;
|
||||
`,
|
||||
[period.startIso, period.endIso, userId]
|
||||
),
|
||||
query(
|
||||
`
|
||||
${baseCte}
|
||||
SELECT
|
||||
organization_id::text AS organization_id,
|
||||
organization_name,
|
||||
COUNT(DISTINCT ticket_id)::int AS tickets,
|
||||
SUM(session_count)::int AS sessions,
|
||||
COALESCE(SUM(total_minutes), 0)::int AS total_minutes,
|
||||
COALESCE(SUM(COALESCE(billed_minutes, 0)), 0)::int AS billed_minutes,
|
||||
COALESCE(SUM(COALESCE(non_billable_minutes, 0)), 0)::int AS non_billable_minutes,
|
||||
COALESCE(SUM(COALESCE(open_minutes, 0)), 0)::int AS open_minutes,
|
||||
COALESCE(SUM(crm_billed_minutes), 0)::int AS crm_billed_minutes
|
||||
FROM session_group_with_crm
|
||||
GROUP BY organization_id, organization_name
|
||||
ORDER BY total_minutes DESC, organization_name ASC;
|
||||
`,
|
||||
[period.startIso, period.endIso, userId]
|
||||
),
|
||||
query(
|
||||
`
|
||||
${baseCte}
|
||||
SELECT
|
||||
work_type,
|
||||
COUNT(DISTINCT ticket_id)::int AS tickets,
|
||||
SUM(session_count)::int AS sessions,
|
||||
COALESCE(SUM(total_minutes), 0)::int AS total_minutes,
|
||||
COALESCE(SUM(COALESCE(billed_minutes, 0)), 0)::int AS billed_minutes,
|
||||
COALESCE(SUM(COALESCE(non_billable_minutes, 0)), 0)::int AS non_billable_minutes,
|
||||
COALESCE(SUM(COALESCE(open_minutes, 0)), 0)::int AS open_minutes,
|
||||
COALESCE(SUM(crm_billed_minutes), 0)::int AS crm_billed_minutes
|
||||
FROM session_group_with_crm
|
||||
GROUP BY work_type
|
||||
ORDER BY total_minutes DESC, work_type ASC;
|
||||
`,
|
||||
[period.startIso, period.endIso, userId]
|
||||
),
|
||||
query(
|
||||
`
|
||||
${baseCte}
|
||||
SELECT
|
||||
ticket_id::text AS ticket_id,
|
||||
ticket_number,
|
||||
MIN(organization_id)::text AS organization_id,
|
||||
MIN(organization_name) AS organization_name,
|
||||
COUNT(DISTINCT day)::int AS active_days,
|
||||
COUNT(*)::int AS sessions,
|
||||
COALESCE(SUM(rounded_minutes), 0)::int AS total_minutes,
|
||||
COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status = 'billed'), 0)::int AS billed_minutes,
|
||||
COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status = 'non_billable'), 0)::int AS non_billable_minutes,
|
||||
COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status IS NULL), 0)::int AS open_minutes,
|
||||
COUNT(*) FILTER (WHERE billing_status IS NULL)::int AS open_sessions,
|
||||
COALESCE((SELECT SUM(crm_billed_minutes)::int FROM ticket_day_billing tdb WHERE tdb.ticket_id = session_base.ticket_id), 0)::int AS crm_billed_minutes
|
||||
FROM session_base
|
||||
GROUP BY ticket_id, ticket_number
|
||||
ORDER BY total_minutes DESC, ticket_number ASC
|
||||
LIMIT 12;
|
||||
`,
|
||||
[period.startIso, period.endIso, userId]
|
||||
),
|
||||
query(
|
||||
`
|
||||
SELECT
|
||||
s.id::text AS id,
|
||||
s.ticket_id::text AS ticket_id,
|
||||
t.ticket_number,
|
||||
COALESCE(so.name, o.name, s.customer_name, t.customer_name, 'Keine Organisation') AS organization_name,
|
||||
s.activity,
|
||||
s.started_at,
|
||||
s.rounded_minutes
|
||||
FROM sessions s
|
||||
JOIN tickets t ON t.id = s.ticket_id
|
||||
LEFT JOIN organizations so ON so.id = s.organization_id
|
||||
LEFT JOIN organizations o ON o.id = t.organization_id
|
||||
WHERE s.started_at >= $1::timestamptz
|
||||
AND s.started_at < $2::timestamptz
|
||||
AND s.user_id = $3
|
||||
AND s.billing_status IS NULL
|
||||
ORDER BY s.started_at ASC
|
||||
LIMIT 12;
|
||||
`,
|
||||
[period.startIso, period.endIso, userId]
|
||||
),
|
||||
query(
|
||||
`
|
||||
${baseCte}
|
||||
SELECT
|
||||
tdb.ticket_id::text AS ticket_id,
|
||||
tdb.ticket_number,
|
||||
MIN(sb.organization_name) AS organization_name,
|
||||
tdb.day::text AS day,
|
||||
tdb.tracked_minutes,
|
||||
tdb.crm_billed_minutes
|
||||
FROM ticket_day_billing tdb
|
||||
JOIN session_base sb ON sb.ticket_id = tdb.ticket_id AND sb.day = tdb.day
|
||||
WHERE NOT tdb.has_crm_value
|
||||
GROUP BY tdb.ticket_id, tdb.ticket_number, tdb.day, tdb.tracked_minutes, tdb.crm_billed_minutes
|
||||
ORDER BY tdb.day ASC, tdb.ticket_number ASC
|
||||
LIMIT 12;
|
||||
`,
|
||||
[period.startIso, period.endIso, userId]
|
||||
),
|
||||
query(
|
||||
`
|
||||
${baseCte}
|
||||
SELECT
|
||||
tdb.ticket_id::text AS ticket_id,
|
||||
tdb.ticket_number,
|
||||
MIN(sb.organization_name) AS organization_name,
|
||||
tdb.day::text AS day,
|
||||
tdb.tracked_minutes,
|
||||
tdb.crm_billed_minutes,
|
||||
(tdb.tracked_minutes - tdb.crm_billed_minutes)::int AS delta_minutes
|
||||
FROM ticket_day_billing tdb
|
||||
JOIN session_base sb ON sb.ticket_id = tdb.ticket_id AND sb.day = tdb.day
|
||||
WHERE tdb.has_crm_value
|
||||
AND tdb.tracked_minutes <> tdb.crm_billed_minutes
|
||||
GROUP BY tdb.ticket_id, tdb.ticket_number, tdb.day, tdb.tracked_minutes, tdb.crm_billed_minutes
|
||||
ORDER BY ABS(tdb.tracked_minutes - tdb.crm_billed_minutes) DESC, tdb.day ASC
|
||||
LIMIT 12;
|
||||
`,
|
||||
[period.startIso, period.endIso, userId]
|
||||
),
|
||||
query<{ closed_at: string }>("SELECT closed_at FROM month_closures WHERE month = $1::date AND user_id = $2;", [period.start, userId])
|
||||
]);
|
||||
|
||||
const totals = totalsResult.rows[0] ?? {
|
||||
tickets: 0,
|
||||
sessions: 0,
|
||||
minutes: 0,
|
||||
billed_minutes: 0,
|
||||
non_billable_minutes: 0,
|
||||
open_minutes: 0,
|
||||
open_sessions: 0,
|
||||
recurring_minutes: 0,
|
||||
manual_minutes: 0,
|
||||
average_session_minutes: 0,
|
||||
active_days: 0,
|
||||
crm_billed_minutes: 0
|
||||
};
|
||||
|
||||
return {
|
||||
periodType: "month",
|
||||
period: period.label,
|
||||
closed: closureResult.rows.length > 0,
|
||||
closedAt: closureResult.rows[0]?.closed_at ?? null,
|
||||
totals: {
|
||||
tickets: totals.tickets,
|
||||
sessions: totals.sessions,
|
||||
minutes: totals.minutes,
|
||||
billedMinutes: totals.billed_minutes,
|
||||
nonBillableMinutes: totals.non_billable_minutes,
|
||||
openMinutes: totals.open_minutes,
|
||||
openSessions: totals.open_sessions,
|
||||
recurringMinutes: totals.recurring_minutes,
|
||||
manualMinutes: totals.manual_minutes,
|
||||
averageSessionMinutes: totals.average_session_minutes,
|
||||
activeDays: totals.active_days,
|
||||
crmBilledMinutes: totals.crm_billed_minutes,
|
||||
crmDeltaMinutes: totals.minutes - totals.crm_billed_minutes
|
||||
},
|
||||
dailySeries: dailyResult.rows,
|
||||
organizations: organizationResult.rows.map((row: any) => ({
|
||||
...row,
|
||||
crm_delta_minutes: row.total_minutes - row.crm_billed_minutes
|
||||
})),
|
||||
workTypes: workTypeResult.rows.map((row: any) => ({
|
||||
...row,
|
||||
crm_delta_minutes: row.total_minutes - row.crm_billed_minutes
|
||||
})),
|
||||
tickets: ticketResult.rows.map((row: any) => ({
|
||||
...row,
|
||||
crm_delta_minutes: row.total_minutes - row.crm_billed_minutes
|
||||
})),
|
||||
attention: {
|
||||
openSessions: openResult.rows,
|
||||
missingCrmDays: missingCrmResult.rows,
|
||||
crmMismatches: mismatchResult.rows
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function getPeriodTicket(config: PeriodConfig, period: ParsedPeriod, ticketId: number, userId: string) {
|
||||
await ensureRecurringSessionsForUserPeriod(userId, period.startIso, period.endIso);
|
||||
|
||||
@@ -2055,6 +2379,11 @@ app.get("/api/periods/:periodType/:period/overview", async (req, res) => {
|
||||
res.json(await getPeriodOverview(config, period, currentUser(req).id));
|
||||
});
|
||||
|
||||
app.get("/api/statistics/months/:month", async (req, res) => {
|
||||
const month = parseMonth(req.params.month);
|
||||
res.json(await getStatisticsOverview(month, currentUser(req).id));
|
||||
});
|
||||
|
||||
app.get("/api/tickets/lookup", async (req, res) => {
|
||||
const ticketNumber = parseTicketNumber(req.query.ticketNumber);
|
||||
const result = await query(
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
BarChart3,
|
||||
CheckCircle2,
|
||||
Command,
|
||||
ChartNoAxesCombined,
|
||||
LayoutGrid,
|
||||
LogOut,
|
||||
Moon,
|
||||
@@ -57,6 +58,7 @@ import { AnalysisPage } from "./views/AnalysisPage";
|
||||
import { LoginPage } from "./views/LoginPage";
|
||||
import { ProfilePage } from "./views/ProfilePage";
|
||||
import { RecurringBillingsPage } from "./views/RecurringBillingsPage";
|
||||
import { StatisticsPage } from "./views/StatisticsPage";
|
||||
import { TicketDetailPage } from "./views/TicketDetailPage";
|
||||
import { TimerPage } from "./views/TimerPage";
|
||||
import type { AuthUser, PeriodType } from "./types";
|
||||
@@ -89,6 +91,10 @@ function routeFromPath(pathname: string) {
|
||||
return { page: "analysis" as const };
|
||||
}
|
||||
|
||||
if (pathname.startsWith("/statistics")) {
|
||||
return { page: "statistics" as const };
|
||||
}
|
||||
|
||||
if (pathname.startsWith("/recurring")) {
|
||||
return { page: "recurring" as const };
|
||||
}
|
||||
@@ -501,6 +507,7 @@ export function App() {
|
||||
const navItems = [
|
||||
{ href: "/timer", label: "Timer", icon: Timer, active: path.startsWith("/timer") || path === "/" },
|
||||
{ href: "/analysis", label: "Auswertung", icon: BarChart3, active: path.startsWith("/analysis") },
|
||||
{ href: "/statistics", label: "Statistiken", icon: ChartNoAxesCombined, active: path.startsWith("/statistics") },
|
||||
{ href: "/recurring", label: "Fixe Abrechnung", icon: Repeat, active: path.startsWith("/recurring") },
|
||||
{ href: "/profile", label: "Profil", icon: UserCog, active: path.startsWith("/profile") },
|
||||
...(currentUser.role === "admin"
|
||||
@@ -685,6 +692,7 @@ export function App() {
|
||||
/>
|
||||
) : null}
|
||||
{route.page === "analysis" ? <AnalysisPage onNavigate={navigate} /> : null}
|
||||
{route.page === "statistics" ? <StatisticsPage onNavigate={navigate} /> : null}
|
||||
{route.page === "recurring" ? <RecurringBillingsPage /> : null}
|
||||
{route.page === "profile" ? <ProfilePage currentUser={currentUser} onUserUpdated={setCurrentUser} /> : null}
|
||||
{route.page === "admin-users" && currentUser.role === "admin" ? <AdminUsersPage currentUser={currentUser} /> : null}
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
PeriodOverview,
|
||||
PeriodType,
|
||||
RecurringBilling,
|
||||
StatisticsOverview,
|
||||
TicketMeta,
|
||||
TicketPeriod,
|
||||
UserRole,
|
||||
@@ -299,6 +300,10 @@ export function getPeriodOverview(type: PeriodType, period: string) {
|
||||
return request<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) {
|
||||
return request<TicketPeriod>(`/api/periods/${periodPath(type)}/${period}/tickets/${ticketId}`);
|
||||
}
|
||||
|
||||
@@ -191,3 +191,94 @@ export type TicketPeriod = {
|
||||
|
||||
export type MonthOverview = PeriodOverview;
|
||||
export type TicketMonth = TicketPeriod;
|
||||
|
||||
export type StatisticsDailyBucket = {
|
||||
day: string;
|
||||
sessions: number;
|
||||
total_minutes: number;
|
||||
billed_minutes: number;
|
||||
non_billable_minutes: number;
|
||||
open_minutes: number;
|
||||
crm_billed_minutes: number;
|
||||
};
|
||||
|
||||
export type StatisticsGroup = {
|
||||
organization_id?: string | null;
|
||||
organization_name?: string;
|
||||
work_type?: WorkType;
|
||||
tickets: number;
|
||||
sessions: number;
|
||||
total_minutes: number;
|
||||
billed_minutes: number;
|
||||
non_billable_minutes: number;
|
||||
open_minutes: number;
|
||||
crm_billed_minutes: number;
|
||||
crm_delta_minutes: number;
|
||||
};
|
||||
|
||||
export type StatisticsTicket = {
|
||||
ticket_id: string;
|
||||
ticket_number: string;
|
||||
organization_id: string | null;
|
||||
organization_name: string;
|
||||
active_days: number;
|
||||
sessions: number;
|
||||
total_minutes: number;
|
||||
billed_minutes: number;
|
||||
non_billable_minutes: number;
|
||||
open_minutes: number;
|
||||
open_sessions: number;
|
||||
crm_billed_minutes: number;
|
||||
crm_delta_minutes: number;
|
||||
};
|
||||
|
||||
export type StatisticsOpenSession = {
|
||||
id: string;
|
||||
ticket_id: string;
|
||||
ticket_number: string;
|
||||
organization_name: string;
|
||||
activity: string;
|
||||
started_at: string;
|
||||
rounded_minutes: number;
|
||||
};
|
||||
|
||||
export type StatisticsCrmDay = {
|
||||
ticket_id: string;
|
||||
ticket_number: string;
|
||||
organization_name: string;
|
||||
day: string;
|
||||
tracked_minutes: number;
|
||||
crm_billed_minutes: number;
|
||||
delta_minutes?: number;
|
||||
};
|
||||
|
||||
export type StatisticsOverview = {
|
||||
periodType: "month";
|
||||
period: string;
|
||||
closed: boolean;
|
||||
closedAt: string | null;
|
||||
totals: {
|
||||
tickets: number;
|
||||
sessions: number;
|
||||
minutes: number;
|
||||
billedMinutes: number;
|
||||
nonBillableMinutes: number;
|
||||
openMinutes: number;
|
||||
openSessions: number;
|
||||
recurringMinutes: number;
|
||||
manualMinutes: number;
|
||||
averageSessionMinutes: number;
|
||||
activeDays: number;
|
||||
crmBilledMinutes: number;
|
||||
crmDeltaMinutes: number;
|
||||
};
|
||||
dailySeries: StatisticsDailyBucket[];
|
||||
organizations: StatisticsGroup[];
|
||||
workTypes: StatisticsGroup[];
|
||||
tickets: StatisticsTicket[];
|
||||
attention: {
|
||||
openSessions: StatisticsOpenSession[];
|
||||
missingCrmDays: StatisticsCrmDay[];
|
||||
crmMismatches: StatisticsCrmDay[];
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user