import { ChevronLeft, ChevronRight, Clock3, ExternalLink, RotateCcw, SlidersHorizontal, Ticket, UserCheck, Users } from "lucide-react"; import type { LucideIcon } from "lucide-react"; import { Fragment, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import { 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { CopyTicketButton } from "@/components/CopyTicketButton"; import { HelpLink } from "@/components/HelpLink"; import { getPeriodOverview } from "../api"; import { currentDay, currentMonth, formatMinutes } from "../format"; import type { PeriodOverview, PeriodType, TicketSummary } from "../types"; type AnalysisPageProps = { onNavigate: (to: string) => void; }; type TimelineBucket = { key: string; label: string; totalMinutes: number; sessionCount: number; }; type TicketGroupBy = "none" | "workType" | "organization" | "status"; type TicketSortBy = "ticket" | "workType" | "organization" | "status"; type TicketSortDirection = "asc" | "desc"; type TicketStatusFilter = "all" | "open" | "evaluated"; type TicketWorkTypeFilter = "all" | "support" | "consulting"; type AnalysisTicketViewSettings = { groupBy: TicketGroupBy; sortBy: TicketSortBy; sortDirection: TicketSortDirection; query: string; workType: TicketWorkTypeFilter; organization: string; status: TicketStatusFilter; }; type TicketSection = { key: string; label: string; tickets: TicketSummary[]; ticketCount: number; totalMinutes: number; openCount: number; }; const analysisTicketViewSettingsKey = "tickettracker.analysis.ticketViewSettings"; const defaultTicketViewSettings: AnalysisTicketViewSettings = { groupBy: "none", sortBy: "ticket", sortDirection: "asc", query: "", workType: "all", organization: "all", status: "all" }; const groupByOptions: Array<{ value: TicketGroupBy; label: string }> = [ { value: "none", label: "Keine" }, { value: "workType", label: "Typ" }, { value: "organization", label: "Organisation" }, { value: "status", label: "Status" } ]; const sortByOptions: Array<{ value: TicketSortBy; label: string }> = [ { value: "ticket", label: "Ticket" }, { value: "workType", label: "Typ" }, { value: "organization", label: "Organisation" }, { value: "status", label: "Status" } ]; function readStoredTicketViewSettings(): AnalysisTicketViewSettings { if (typeof window === "undefined") { return defaultTicketViewSettings; } try { const raw = window.localStorage.getItem(analysisTicketViewSettingsKey); if (!raw) { return defaultTicketViewSettings; } const parsed = JSON.parse(raw) as Partial; return { groupBy: groupByOptions.find((option) => option.value === parsed.groupBy)?.value ?? defaultTicketViewSettings.groupBy, sortBy: sortByOptions.find((option) => option.value === parsed.sortBy)?.value ?? defaultTicketViewSettings.sortBy, sortDirection: parsed.sortDirection === "desc" ? "desc" : defaultTicketViewSettings.sortDirection, query: typeof parsed.query === "string" ? parsed.query : defaultTicketViewSettings.query, workType: parsed.workType === "support" || parsed.workType === "consulting" ? parsed.workType : defaultTicketViewSettings.workType, organization: typeof parsed.organization === "string" ? parsed.organization : defaultTicketViewSettings.organization, status: parsed.status === "open" || parsed.status === "evaluated" ? parsed.status : defaultTicketViewSettings.status }; } catch { return defaultTicketViewSettings; } } function TicketStatus({ ticket }: { ticket: TicketSummary }) { const ticketOpenCount = ticket.ticket_open_count ?? ticket.open_count; if (ticketOpenCount > 0) { return {ticketOpenCount} offen; } return bewertet; } function ticketStatusKey(ticket: TicketSummary): "open" | "evaluated" { return (ticket.ticket_open_count ?? ticket.open_count) > 0 ? "open" : "evaluated"; } function ticketStatusLabel(ticket: TicketSummary) { return ticketStatusKey(ticket) === "open" ? "Offen" : "Bewertet"; } function workTypeLabel(workType: TicketSummary["work_type"]) { if (workType === "support") { return "Support"; } if (workType === "consulting") { return "Consulting"; } return "Ohne Typ"; } function organizationLabel(ticket: TicketSummary) { return ticket.customer_name ?? ticket.organization_name ?? "Keine Organisation"; } function organizationKey(ticket: TicketSummary) { return ticket.organization_id ? `id:${ticket.organization_id}` : `name:${organizationLabel(ticket)}`; } function compareText(left: string, right: string) { return left.localeCompare(right, "de", { numeric: true, sensitivity: "base" }); } function sortTickets(tickets: TicketSummary[], sortBy: TicketSortBy, direction: TicketSortDirection) { const sorted = [...tickets].sort((left, right) => { let result = 0; if (sortBy === "ticket") { result = compareText(left.ticket_number, right.ticket_number); } else if (sortBy === "workType") { result = compareText(workTypeLabel(left.work_type), workTypeLabel(right.work_type)); } else if (sortBy === "organization") { result = compareText(organizationLabel(left), organizationLabel(right)); } else { result = compareText(ticketStatusLabel(left), ticketStatusLabel(right)); } if (result === 0) { result = compareText(left.ticket_number, right.ticket_number); } return direction === "asc" ? result : -result; }); return sorted; } function groupTicketSections(tickets: TicketSummary[], groupBy: TicketGroupBy): TicketSection[] { if (groupBy === "none") { return [ { key: "all", label: "Alle Tickets", tickets, ticketCount: tickets.length, totalMinutes: tickets.reduce((sum, ticket) => sum + ticket.total_minutes, 0), openCount: tickets.reduce((sum, ticket) => sum + (ticket.ticket_open_count ?? ticket.open_count), 0) } ]; } const groups = new Map(); for (const ticket of tickets) { const key = groupBy === "workType" ? `workType:${ticket.work_type ?? "none"}` : groupBy === "organization" ? organizationKey(ticket) : `status:${ticketStatusKey(ticket)}`; const label = groupBy === "workType" ? workTypeLabel(ticket.work_type) : groupBy === "organization" ? organizationLabel(ticket) : ticketStatusLabel(ticket); const existing = groups.get(key); if (existing) { existing.tickets.push(ticket); existing.ticketCount += 1; existing.totalMinutes += ticket.total_minutes; existing.openCount += ticket.ticket_open_count ?? ticket.open_count; continue; } groups.set(key, { key, label, tickets: [ticket], ticketCount: 1, totalMinutes: ticket.total_minutes, openCount: ticket.ticket_open_count ?? ticket.open_count }); } return Array.from(groups.values()).sort((left, right) => { if (groupBy === "status") { return left.key === "status:open" ? -1 : right.key === "status:open" ? 1 : compareText(left.label, right.label); } return compareText(left.label, right.label); }); } 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 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 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 buildTimeline(periodType: PeriodType, period: string, overview: PeriodOverview | null): TimelineBucket[] { const series = new Map((overview?.activitySeries ?? []).map((bucket) => [bucket.bucket_key, bucket])); if (periodType === "day") { return Array.from({ length: 24 }).map((_, hour) => { const key = String(hour).padStart(2, "0"); const bucket = series.get(key); return { key, label: `${key}:00`, totalMinutes: bucket?.total_minutes ?? 0, sessionCount: bucket?.session_count ?? 0 }; }); } const [year, month] = period.split("-").map(Number); const daysInMonth = Number.isFinite(year) && Number.isFinite(month) ? new Date(year, month, 0).getDate() : 31; return Array.from({ length: daysInMonth }).map((_, index) => { const day = String(index + 1).padStart(2, "0"); const key = `${period}-${day}`; const bucket = series.get(key); return { key, label: day, totalMinutes: bucket?.total_minutes ?? 0, sessionCount: bucket?.session_count ?? 0 }; }); } export function AnalysisPage({ onNavigate }: AnalysisPageProps) { const [periodType, setPeriodType] = useState("month"); const [period, setPeriod] = useState(currentMonth()); const [overview, setOverview] = useState(null); const [loading, setLoading] = useState(false); const [ticketViewSettings, setTicketViewSettings] = useState(() => readStoredTicketViewSettings()); const loadRequestId = useRef(0); useEffect(() => { window.localStorage.setItem(analysisTicketViewSettingsKey, JSON.stringify(ticketViewSettings)); }, [ticketViewSettings]); async function load() { const requestId = loadRequestId.current + 1; loadRequestId.current = requestId; setLoading(true); try { const nextOverview = await getPeriodOverview(periodType, period); if (requestId === loadRequestId.current) { setOverview(nextOverview); } } catch (error) { if (requestId === loadRequestId.current) { toast.error("Auswertung konnte nicht geladen werden", { description: error instanceof Error ? error.message : "Unbekannter Fehler" }); } } finally { if (requestId === loadRequestId.current) { setLoading(false); } } } useEffect(() => { void load(); }, [periodType, period]); 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); }; }, [periodType, period]); function switchPeriodType(nextType: PeriodType) { setPeriodType(nextType); setPeriod(nextType === "month" ? currentMonth() : currentDay()); } function changePeriodBy(delta: number) { if (periodType === "month") { const [year, month] = period.split("-").map(Number); const date = Number.isFinite(year) && Number.isFinite(month) ? new Date(year, month - 1, 1) : new Date(); date.setMonth(date.getMonth() + delta); setPeriod(`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`); } else { const [year, month, day] = period.split("-").map(Number); const date = Number.isFinite(year) && Number.isFinite(month) && Number.isFinite(day) ? new Date(year, month - 1, day) : new Date(); date.setDate(date.getDate() + delta); setPeriod(`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`); } } const totals = overview?.totals; const periodLabel = periodType === "month" ? "Monat" : "Tag"; const metricCards: Array<{ label: string; value: string | number; detail?: string; icon: LucideIcon }> = [ { label: "Tickets", value: totals?.tickets ?? 0, icon: Ticket }, { label: "Sessions", value: totals?.sessions ?? 0, icon: Users }, { label: "Abgerechnet", value: formatMinutes(totals?.minutes ?? 0), detail: `Teamspace ${formatMinutes(totals?.crmBilledMinutes ?? 0)}`, icon: Clock3 }, { label: "Nicht abrechenbar", value: formatMinutes(totals?.nonBillableMinutes ?? 0), icon: UserCheck }, { label: "Offen", value: totals?.openSessions ?? 0, detail: formatMinutes(totals?.openMinutes ?? 0), icon: UserCheck } ]; const tickets = overview?.tickets ?? []; const organizationOptions = useMemo(() => { const options = new Map(); for (const ticket of tickets) { options.set(organizationKey(ticket), organizationLabel(ticket)); } return Array.from(options.entries()) .map(([value, label]) => ({ value, label })) .sort((left, right) => compareText(left.label, right.label)); }, [tickets]); const visibleTickets = useMemo(() => { const normalizedQuery = ticketViewSettings.query.trim().toLocaleLowerCase("de"); const filtered = tickets.filter((ticket) => { const matchesQuery = !normalizedQuery || ticket.ticket_number.toLocaleLowerCase("de").includes(normalizedQuery) || organizationLabel(ticket).toLocaleLowerCase("de").includes(normalizedQuery); const matchesWorkType = ticketViewSettings.workType === "all" || ticket.work_type === ticketViewSettings.workType; const matchesOrganization = ticketViewSettings.organization === "all" || organizationKey(ticket) === ticketViewSettings.organization; const matchesStatus = ticketViewSettings.status === "all" || ticketStatusKey(ticket) === ticketViewSettings.status; return matchesQuery && matchesWorkType && matchesOrganization && matchesStatus; }); return sortTickets(filtered, ticketViewSettings.sortBy, ticketViewSettings.sortDirection); }, [tickets, ticketViewSettings]); const ticketSections = useMemo(() => groupTicketSections(visibleTickets, ticketViewSettings.groupBy), [visibleTickets, ticketViewSettings.groupBy]); const activeFilterCount = [ ticketViewSettings.query.trim(), ticketViewSettings.workType !== "all", ticketViewSettings.organization !== "all", ticketViewSettings.status !== "all", ticketViewSettings.groupBy !== "none", ticketViewSettings.sortBy !== "ticket", ticketViewSettings.sortDirection !== "asc" ].filter(Boolean).length; const timeline = buildTimeline(periodType, period, overview); const chartWidth = 900; const chartHeight = 180; const chartPadding = { top: 18, right: 18, bottom: 34, left: 64 }; const chartInnerWidth = chartWidth - chartPadding.left - chartPadding.right; const chartInnerHeight = chartHeight - chartPadding.top - chartPadding.bottom; const maxMinutes = Math.max(...timeline.map((bucket) => bucket.totalMinutes), 1); const chartMaxMinutes = niceCeilMinutes(maxMinutes); const yAxisTicks = Array.from({ length: 5 }).map((_, index) => chartMaxMinutes - (index * chartMaxMinutes) / 4); const barWidth = Math.max(4, Math.min(18, chartInnerWidth / Math.max(timeline.length, 1) / 1.8)); const chartPoints = timeline.map((bucket, index) => { const x = timeline.length === 1 ? chartPadding.left + chartInnerWidth / 2 : chartPadding.left + (index * chartInnerWidth) / (timeline.length - 1); return { bucket, x, barHeight: bucket.totalMinutes > 0 ? Math.max(2, (bucket.totalMinutes / chartMaxMinutes) * chartInnerHeight) : 0, y: chartPadding.top + (1 - bucket.totalMinutes / chartMaxMinutes) * chartInnerHeight }; }); const effortPath = buildChartPath(chartPoints.filter((point) => point.bucket.totalMinutes > 0).map((point) => ({ x: point.x, y: point.y }))); const labelStep = periodType === "month" ? Math.max(1, Math.ceil(timeline.length / 8)) : 3; return (

Auswertung

{periodType === "month" ? "Tickets prüfen, Sessions bewerten und Zeiten kontrollieren." : "Sessions des Tages prüfen und bewerten."}

{loading ? lädt... : null}
setPeriod(event.currentTarget.value)} />
{metricCards.map(({ label, value, detail, icon: Icon }) => (

{label}

{value}

{detail ?

{detail}

: null}
))}
Abgerechneter Aufwand {periodType === "month" ? "Abrechenbare Zeit pro Tag im ausgewählten Monat." : "Abrechenbare Zeit pro Stunde am ausgewählten Tag."}
{formatMinutes(totals?.minutes ?? 0)}
{(totals?.minutes ?? 0) > 0 ? ( {yAxisTicks.map((tick) => { const y = chartPadding.top + (1 - tick / chartMaxMinutes) * chartInnerHeight; return ( {formatAxisMinutes(tick)} ); })} {chartPoints.map((point) => { const barY = chartPadding.top + chartInnerHeight - point.barHeight; return ( {point.bucket.totalMinutes > 0 ? ( ) : null} ); })} {effortPath ? : null} {chartPoints.filter((point) => point.bucket.totalMinutes > 0).map((point) => ( ))} {chartPoints.map((point, index) => index % labelStep === 0 || index === chartPoints.length - 1 ? ( {point.bucket.label} ) : null )} ) : (
Keine Daten für den gewählten Zeitraum.
)}
Abgerechnet Verlauf
Tickets im Zeitraum {periodType === "month" ? "Sessions prüfen, bewerten und bei Bedarf Tickets öffnen." : "Tagesansicht ohne eigenen Abschluss."}

Ansicht

{visibleTickets.length} von {tickets.length} Ticket(s)

{activeFilterCount > 0 ? {activeFilterCount} aktiv : null}
setTicketViewSettings((current) => ({ ...current, query: event.currentTarget.value }))} />
Ticket Organisation Art Sessions Zeit abgerechnet Sessions abgerechnet Nicht abrechenbar Status {ticketSections.map((section, sectionIndex) => ( {ticketViewSettings.groupBy !== "none" ? ( 0 ? "border-t-[12px] border-t-background" : ""} border-b-2 border-border bg-muted/70 hover:bg-muted/70`}>
Gruppe {section.label}
{section.ticketCount} Ticket(s) {formatMinutes(section.totalMinutes)} {section.openCount > 0 ? {section.openCount} offen : bewertet}
) : null} {section.tickets.map((ticket) => (
{ticket.ticket_number}
{ticket.customer_name ?? "-"} {workTypeLabel(ticket.work_type)} {ticket.session_count} {formatMinutes(ticket.total_minutes)} {ticket.billed_count} {ticket.non_billable_count} / {formatMinutes(ticket.non_billable_minutes)}
))}
))}
{ticketSections.map((section, sectionIndex) => (
0 ? "pt-3" : ""}`}> {ticketViewSettings.groupBy !== "none" ? (
Gruppe {section.label}
{section.ticketCount} Ticket(s) {formatMinutes(section.totalMinutes)} {section.openCount > 0 ? {section.openCount} offen : bewertet}
) : null} {section.tickets.map((ticket) => (

{ticket.ticket_number}

{ticket.customer_name ?? "Keine Organisation"} · {workTypeLabel(ticket.work_type)} · {ticket.session_count} Session(s)

Abr.

{formatMinutes(ticket.total_minutes)}

Abr.

{ticket.billed_count}

Nicht

{formatMinutes(ticket.non_billable_minutes)}

))}
))}
{tickets.length === 0 ? (

Für diesen Zeitraum sind noch keine Sessions vorhanden.

) : visibleTickets.length === 0 ? (

Keine Tickets passend zu den aktuellen Filtern.

) : null}
); }