Initial TicketTracker release
This commit is contained in:
@@ -0,0 +1,960 @@
|
||||
import { CheckCircle2, ChevronLeft, ChevronRight, CircleAlert, Clock3, ExternalLink, LockOpen, RotateCcw, SlidersHorizontal, Ticket, UserCheck, Users } from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { Fragment, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Alert } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { CopyTicketButton } from "@/components/CopyTicketButton";
|
||||
import { closePeriod, getPeriodOverview, reopenPeriod } from "../api";
|
||||
import { currentDay, currentMonth, formatDateTime, formatMinutes } from "../format";
|
||||
import 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<AnalysisTicketViewSettings>;
|
||||
|
||||
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 <Badge variant="warning">{ticketOpenCount} offen</Badge>;
|
||||
}
|
||||
|
||||
return <Badge variant="success">bewertet</Badge>;
|
||||
}
|
||||
|
||||
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<string, TicketSection>();
|
||||
|
||||
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<PeriodType>("month");
|
||||
const [period, setPeriod] = useState(currentMonth());
|
||||
const [overview, setOverview] = useState<PeriodOverview | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [closing, setClosing] = useState(false);
|
||||
const [reopening, setReopening] = useState(false);
|
||||
const [showOpen, setShowOpen] = useState(false);
|
||||
const [ticketViewSettings, setTicketViewSettings] = useState<AnalysisTicketViewSettings>(() => 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());
|
||||
setShowOpen(false);
|
||||
}
|
||||
|
||||
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")}`);
|
||||
}
|
||||
|
||||
setShowOpen(false);
|
||||
}
|
||||
|
||||
async function finishPeriod() {
|
||||
setClosing(true);
|
||||
try {
|
||||
await closePeriod(periodType, period);
|
||||
toast.success(`${periodType === "month" ? "Monat" : "Tag"} abgeschlossen`, {
|
||||
description: `${period} ist abgeschlossen.`
|
||||
});
|
||||
await load();
|
||||
} catch (error) {
|
||||
toast.error(`${periodType === "month" ? "Monat" : "Tag"} noch nicht abschließbar`, {
|
||||
description: error instanceof Error ? error.message : "Bitte offene Sessions prüfen."
|
||||
});
|
||||
setShowOpen(true);
|
||||
await load();
|
||||
} finally {
|
||||
setClosing(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function openPeriodAgain() {
|
||||
setReopening(true);
|
||||
try {
|
||||
await reopenPeriod(periodType, period);
|
||||
toast.success(`${periodType === "month" ? "Monat" : "Tag"} wieder geöffnet`, {
|
||||
description: `${period} kann wieder bearbeitet werden.`
|
||||
});
|
||||
await load();
|
||||
} catch (error) {
|
||||
toast.error(`${periodType === "month" ? "Monat" : "Tag"} konnte nicht geöffnet werden`, {
|
||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||
});
|
||||
} finally {
|
||||
setReopening(false);
|
||||
}
|
||||
}
|
||||
|
||||
const totals = overview?.totals;
|
||||
const periodLabel = periodType === "month" ? "Monat" : "Tag";
|
||||
const supportsPeriodClosure = periodType === "month";
|
||||
const metricCards: Array<{ label: string; value: string | number; detail?: string; icon: LucideIcon }> = [
|
||||
{ label: "Tickets", value: totals?.tickets ?? 0, icon: Ticket },
|
||||
{ label: "Sessions", value: totals?.sessions ?? 0, icon: Users },
|
||||
{ label: "Zeit", value: formatMinutes(totals?.minutes ?? 0), detail: `Teamspace ${formatMinutes(totals?.crmBilledMinutes ?? 0)}`, icon: Clock3 },
|
||||
{ label: "Offen", value: totals?.openSessions ?? 0, icon: UserCheck }
|
||||
];
|
||||
const tickets = overview?.tickets ?? [];
|
||||
const organizationOptions = useMemo(() => {
|
||||
const options = new Map<string, string>();
|
||||
|
||||
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 (
|
||||
<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">Auswertung</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{supportsPeriodClosure ? "Tickets prüfen, Sessions markieren und Monat abschließen." : "Sessions des Tages prüfen und bewerten."}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-[auto_minmax(220px,300px)] sm:items-end">
|
||||
<div className="grid grid-cols-2 rounded-md border bg-background p-1">
|
||||
<Button size="sm" variant={periodType === "month" ? "default" : "ghost"} className="h-8" onClick={() => switchPeriodType("month")}>
|
||||
Monat
|
||||
</Button>
|
||||
<Button size="sm" variant={periodType === "day" ? "default" : "ghost"} className="h-8" onClick={() => switchPeriodType("day")}>
|
||||
Tag
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<label className="text-xs font-medium text-muted-foreground" htmlFor="period">
|
||||
{periodLabel}
|
||||
</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={() => changePeriodBy(-1)} aria-label={`${periodLabel} zurück`}>
|
||||
<ChevronLeft className="size-4" />
|
||||
</Button>
|
||||
<Input
|
||||
id="period"
|
||||
className="h-9"
|
||||
type={periodType === "month" ? "month" : "date"}
|
||||
value={period}
|
||||
onChange={(event) => {
|
||||
setPeriod(event.currentTarget.value);
|
||||
setShowOpen(false);
|
||||
}}
|
||||
/>
|
||||
<Button type="button" size="icon" variant="secondary" className="h-9 w-9" onClick={() => changePeriodBy(1)} aria-label={`${periodLabel} vor`}>
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{metricCards.map(({ label, value, detail, icon: Icon }) => (
|
||||
<Card key={label}>
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="gap-3 p-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
|
||||
<div>
|
||||
<CardTitle>Gesamtaufwand</CardTitle>
|
||||
<CardDescription>
|
||||
{periodType === "month" ? "Aufwand pro Tag im ausgewählten Monat." : "Aufwand pro Stunde am ausgewählten Tag."}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant="outline">{formatMinutes(totals?.minutes ?? 0)}</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 pb-4">
|
||||
<div className="h-44 w-full overflow-hidden rounded-md border bg-background">
|
||||
{(totals?.minutes ?? 0) > 0 ? (
|
||||
<svg viewBox={`0 0 ${chartWidth} ${chartHeight}`} className="h-full w-full">
|
||||
{yAxisTicks.map((tick) => {
|
||||
const y = chartPadding.top + (1 - tick / chartMaxMinutes) * chartInnerHeight;
|
||||
|
||||
return (
|
||||
<g key={tick}>
|
||||
<line x1={chartPadding.left} x2={chartWidth - chartPadding.right} y1={y} y2={y} stroke="currentColor" className="text-muted/70" />
|
||||
<line x1={chartPadding.left - 5} x2={chartPadding.left} y1={y} y2={y} stroke="currentColor" className="text-muted-foreground/70" />
|
||||
<text x={chartPadding.left - 9} y={y + 4} textAnchor="end" className="fill-muted-foreground text-[11px]">
|
||||
{formatAxisMinutes(tick)}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
<line
|
||||
x1={chartPadding.left}
|
||||
x2={chartPadding.left}
|
||||
y1={chartPadding.top}
|
||||
y2={chartPadding.top + chartInnerHeight}
|
||||
stroke="currentColor"
|
||||
className="text-muted-foreground/70"
|
||||
/>
|
||||
|
||||
{chartPoints.map((point) => {
|
||||
const barY = chartPadding.top + chartInnerHeight - point.barHeight;
|
||||
|
||||
return (
|
||||
<g key={point.bucket.key}>
|
||||
{point.bucket.totalMinutes > 0 ? (
|
||||
<rect
|
||||
x={point.x - barWidth / 2}
|
||||
y={barY}
|
||||
width={barWidth}
|
||||
height={point.barHeight}
|
||||
rx="4"
|
||||
className="fill-neutral-300 dark:fill-neutral-700"
|
||||
/>
|
||||
) : null}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{effortPath ? <path d={effortPath} fill="none" stroke="currentColor" strokeWidth="2.5" className="text-foreground" /> : null}
|
||||
|
||||
{chartPoints.filter((point) => point.bucket.totalMinutes > 0).map((point) => (
|
||||
<circle key={`${point.bucket.key}-effort`} cx={point.x} cy={point.y} r="3" className="fill-background stroke-foreground" strokeWidth="2" />
|
||||
))}
|
||||
|
||||
{chartPoints.map((point, index) =>
|
||||
index % labelStep === 0 || index === chartPoints.length - 1 ? (
|
||||
<text key={`${point.bucket.key}-label`} x={point.x} y={chartHeight - 10} 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 Daten für den gewählten Zeitraum.</div>
|
||||
)}
|
||||
</div>
|
||||
<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" />
|
||||
Minuten
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="h-0.5 w-4 bg-foreground" />
|
||||
Verlauf
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{supportsPeriodClosure && overview?.closed ? (
|
||||
<Alert variant="success" className="flex items-center gap-2 py-3">
|
||||
<CheckCircle2 className="size-4 shrink-0" />
|
||||
<span>Dieser Monat wurde am {formatDateTime(overview.closedAt!)} abgeschlossen.</span>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{overview && overview.openSessions.length > 0 ? (
|
||||
<Alert variant="warning" className="flex flex-col gap-2 py-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<CircleAlert className="size-4 shrink-0" />
|
||||
<span>Es gibt noch {overview.openSessions.length} Session(s) ohne Auswahl.</span>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => setShowOpen((value) => !value)}>
|
||||
Offene Sessions prüfen
|
||||
</Button>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{showOpen && overview?.openSessions.length ? (
|
||||
<Card>
|
||||
<CardHeader className="p-4">
|
||||
<CardTitle>Nicht bearbeitete Sessions</CardTitle>
|
||||
<CardDescription>Diese Einträge blockieren den Monatsabschluss.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 px-4 pb-4">
|
||||
{overview.openSessions.map((session) => (
|
||||
<div key={session.id} className="grid gap-2 rounded-md border 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">{session.ticket_number}</span>
|
||||
<Badge variant="outline">{formatMinutes(session.rounded_minutes)}</Badge>
|
||||
</div>
|
||||
<p className="truncate text-sm text-muted-foreground">{session.customer_name}</p>
|
||||
<p className="text-sm">{session.activity}</p>
|
||||
</div>
|
||||
<Button variant="secondary" size="sm" onClick={() => onNavigate(`/analysis/${periodType}/${period}/tickets/${session.ticket_id}`)}>
|
||||
Öffnen
|
||||
<ExternalLink className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<Card>
|
||||
<CardHeader className="gap-3 p-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
|
||||
<div>
|
||||
<CardTitle>Tickets im Zeitraum</CardTitle>
|
||||
<CardDescription>
|
||||
{supportsPeriodClosure ? "Sessions prüfen und danach den Monat abschließen." : "Tagesansicht ohne eigenen Abschluss."}
|
||||
</CardDescription>
|
||||
</div>
|
||||
{supportsPeriodClosure ? (
|
||||
overview?.closed ? (
|
||||
<Button size="sm" variant="secondary" disabled={reopening} onClick={openPeriodAgain}>
|
||||
<LockOpen className="size-4" />
|
||||
{reopening ? "Öffnet..." : "Monat wieder öffnen"}
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="sm" variant={overview?.canClose ? "default" : "secondary"} disabled={!overview?.canClose || closing} onClick={finishPeriod}>
|
||||
<CheckCircle2 className="size-4" />
|
||||
{closing ? "Schließt..." : overview?.canClose ? "Monat abschließen" : "Noch nicht abschließbar"}
|
||||
</Button>
|
||||
)
|
||||
) : null}
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 pb-4">
|
||||
<div className="mb-4 rounded-md border bg-muted/20 p-3">
|
||||
<div className="mb-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<SlidersHorizontal className="size-4 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">Ansicht</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{visibleTickets.length} von {tickets.length} Ticket(s)
|
||||
</p>
|
||||
</div>
|
||||
{activeFilterCount > 0 ? <Badge variant="outline">{activeFilterCount} aktiv</Badge> : null}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
disabled={activeFilterCount === 0}
|
||||
onClick={() => setTicketViewSettings(defaultTicketViewSettings)}
|
||||
>
|
||||
<RotateCcw className="size-3.5" />
|
||||
Zurücksetzen
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 md:grid-cols-2 xl:grid-cols-[minmax(180px,1fr)_150px_190px_140px_140px_140px_120px]">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground" htmlFor="ticket-filter-query">Suche</label>
|
||||
<Input
|
||||
id="ticket-filter-query"
|
||||
className="h-8"
|
||||
value={ticketViewSettings.query}
|
||||
placeholder="Ticket oder Organisation"
|
||||
onChange={(event) => setTicketViewSettings((current) => ({ ...current, query: event.currentTarget.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">Typ</label>
|
||||
<Select value={ticketViewSettings.workType} onValueChange={(value) => setTicketViewSettings((current) => ({ ...current, workType: value as TicketWorkTypeFilter }))}>
|
||||
<SelectTrigger size="sm" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Alle Typen</SelectItem>
|
||||
<SelectItem value="support">Support</SelectItem>
|
||||
<SelectItem value="consulting">Consulting</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">Organisation</label>
|
||||
<Select value={ticketViewSettings.organization} onValueChange={(value) => setTicketViewSettings((current) => ({ ...current, organization: value }))}>
|
||||
<SelectTrigger size="sm" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Alle Organisationen</SelectItem>
|
||||
{organizationOptions.map((organization) => (
|
||||
<SelectItem key={organization.value} value={organization.value}>
|
||||
{organization.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">Status</label>
|
||||
<Select value={ticketViewSettings.status} onValueChange={(value) => setTicketViewSettings((current) => ({ ...current, status: value as TicketStatusFilter }))}>
|
||||
<SelectTrigger size="sm" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Alle Status</SelectItem>
|
||||
<SelectItem value="open">Offen</SelectItem>
|
||||
<SelectItem value="evaluated">Bewertet</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">Gruppierung</label>
|
||||
<Select value={ticketViewSettings.groupBy} onValueChange={(value) => setTicketViewSettings((current) => ({ ...current, groupBy: value as TicketGroupBy }))}>
|
||||
<SelectTrigger size="sm" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{groupByOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">Sortierung</label>
|
||||
<Select value={ticketViewSettings.sortBy} onValueChange={(value) => setTicketViewSettings((current) => ({ ...current, sortBy: value as TicketSortBy }))}>
|
||||
<SelectTrigger size="sm" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sortByOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">Richtung</label>
|
||||
<Select value={ticketViewSettings.sortDirection} onValueChange={(value) => setTicketViewSettings((current) => ({ ...current, sortDirection: value as TicketSortDirection }))}>
|
||||
<SelectTrigger size="sm" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="asc">Aufsteigend</SelectItem>
|
||||
<SelectItem value="desc">Absteigend</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hidden md:block">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Ticket</TableHead>
|
||||
<TableHead>Organisation</TableHead>
|
||||
<TableHead>Art</TableHead>
|
||||
<TableHead>Sessions</TableHead>
|
||||
<TableHead>Zeit</TableHead>
|
||||
<TableHead>Abgerechnet</TableHead>
|
||||
<TableHead>Nicht abrechenbar</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{ticketSections.map((section) => (
|
||||
<Fragment key={section.key}>
|
||||
{ticketViewSettings.groupBy !== "none" ? (
|
||||
<TableRow className="bg-muted/40 hover:bg-muted/40">
|
||||
<TableCell colSpan={9}>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-semibold">{section.label}</span>
|
||||
<Badge variant="outline">{section.ticketCount} Ticket(s)</Badge>
|
||||
<Badge variant="outline">{formatMinutes(section.totalMinutes)}</Badge>
|
||||
{section.openCount > 0 ? <Badge variant="warning">{section.openCount} offen</Badge> : <Badge variant="success">bewertet</Badge>}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : null}
|
||||
{section.tickets.map((ticket) => (
|
||||
<TableRow key={ticket.id}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="font-semibold">{ticket.ticket_number}</span>
|
||||
<CopyTicketButton ticketNumber={ticket.ticket_number} />
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{ticket.customer_name ?? "-"}</TableCell>
|
||||
<TableCell>{workTypeLabel(ticket.work_type)}</TableCell>
|
||||
<TableCell>{ticket.session_count}</TableCell>
|
||||
<TableCell>{formatMinutes(ticket.total_minutes)}</TableCell>
|
||||
<TableCell>{ticket.billed_count}</TableCell>
|
||||
<TableCell>{ticket.non_billable_count}</TableCell>
|
||||
<TableCell>
|
||||
<TicketStatus ticket={ticket} />
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button variant="ghost" size="sm" onClick={() => onNavigate(`/analysis/${periodType}/${period}/tickets/${ticket.id}`)}>
|
||||
Öffnen
|
||||
<ExternalLink className="size-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</Fragment>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 md:hidden">
|
||||
{ticketSections.map((section) => (
|
||||
<div key={section.key} className="space-y-2">
|
||||
{ticketViewSettings.groupBy !== "none" ? (
|
||||
<div className="flex flex-wrap items-center gap-2 rounded-md border bg-muted/40 px-3 py-2">
|
||||
<span className="font-semibold">{section.label}</span>
|
||||
<Badge variant="outline">{section.ticketCount} Ticket(s)</Badge>
|
||||
<Badge variant="outline">{formatMinutes(section.totalMinutes)}</Badge>
|
||||
{section.openCount > 0 ? <Badge variant="warning">{section.openCount} offen</Badge> : <Badge variant="success">bewertet</Badge>}
|
||||
</div>
|
||||
) : null}
|
||||
{section.tickets.map((ticket) => (
|
||||
<div key={ticket.id} className="space-y-2 rounded-md border bg-background p-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1">
|
||||
<p className="truncate font-semibold">{ticket.ticket_number}</p>
|
||||
<CopyTicketButton ticketNumber={ticket.ticket_number} />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{ticket.customer_name ?? "Keine Organisation"} · {workTypeLabel(ticket.work_type)} · {ticket.session_count} Session(s)</p>
|
||||
</div>
|
||||
<TicketStatus ticket={ticket} />
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 text-sm">
|
||||
<div className="rounded-md bg-muted/50 p-2">
|
||||
<p className="text-muted-foreground">Zeit</p>
|
||||
<p className="font-medium">{formatMinutes(ticket.total_minutes)}</p>
|
||||
</div>
|
||||
<div className="rounded-md bg-muted/50 p-2">
|
||||
<p className="text-muted-foreground">Abr.</p>
|
||||
<p className="font-medium">{ticket.billed_count}</p>
|
||||
</div>
|
||||
<div className="rounded-md bg-muted/50 p-2">
|
||||
<p className="text-muted-foreground">Nicht</p>
|
||||
<p className="font-medium">{ticket.non_billable_count}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button className="w-full" size="sm" variant="secondary" onClick={() => onNavigate(`/analysis/${periodType}/${period}/tickets/${ticket.id}`)}>
|
||||
Öffnen
|
||||
<ExternalLink className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tickets.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">Für diesen Zeitraum sind noch keine Sessions vorhanden.</p>
|
||||
) : visibleTickets.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">Keine Tickets passend zu den aktuellen Filtern.</p>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user