869 lines
36 KiB
TypeScript
869 lines
36 KiB
TypeScript
import { AlertCircle, ArrowDown, ArrowUp, ChevronLeft, ChevronRight, Clock3, Download, ExternalLink, RotateCcw, SlidersHorizontal, Ticket, UserCheck, Users } from "lucide-react";
|
|
import type { LucideIcon } from "lucide-react";
|
|
import { Fragment, useEffect, useMemo, useRef, useState } from "react";
|
|
import { toast } from "sonner";
|
|
import { Alert } from "@/components/ui/alert";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
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 { MultiFilterDropdown, type MultiFilterOption } from "@/components/MultiFilterDropdown";
|
|
import { downloadMonthExport, getPeriodOverview } from "../api";
|
|
import { currentDay, currentMonth, formatDateTime, formatMinutes } from "../format";
|
|
import type { AuthUser, PeriodOverview, PeriodType, TicketSummary } from "../types";
|
|
|
|
type AnalysisPageProps = {
|
|
currentUser: AuthUser;
|
|
onNavigate: (to: string) => void;
|
|
};
|
|
|
|
type TicketGroupBy = "none" | "workType" | "organization" | "status";
|
|
type TicketSortBy = "ticket" | "workType" | "organization" | "status";
|
|
type TicketSortDirection = "asc" | "desc";
|
|
type TicketFilterOption = MultiFilterOption;
|
|
|
|
type AnalysisTicketViewSettings = {
|
|
groupBy: TicketGroupBy;
|
|
sortBy: TicketSortBy;
|
|
sortDirection: TicketSortDirection;
|
|
query: string;
|
|
filterValues: string[];
|
|
organizationValues: string[];
|
|
};
|
|
|
|
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: "",
|
|
filterValues: [],
|
|
organizationValues: []
|
|
};
|
|
|
|
const ticketStaticFilterOptions: TicketFilterOption[] = [
|
|
{ value: "workType:support", label: "Support" },
|
|
{ value: "workType:consulting", label: "Consulting" },
|
|
{ value: "status:open", label: "Offen" },
|
|
{ value: "status:evaluated", label: "Bewertet" }
|
|
];
|
|
|
|
const ticketStaticFilterValues = new Set(ticketStaticFilterOptions.map((option) => option.value));
|
|
|
|
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 isKnownTicketFilterValue(value: unknown): value is string {
|
|
if (typeof value !== "string") {
|
|
return false;
|
|
}
|
|
|
|
return ticketStaticFilterValues.has(value);
|
|
}
|
|
|
|
function normalizedTicketFilterValues(values: unknown) {
|
|
if (!Array.isArray(values)) {
|
|
return defaultTicketViewSettings.filterValues;
|
|
}
|
|
|
|
return Array.from(new Set(values.filter(isKnownTicketFilterValue)));
|
|
}
|
|
|
|
function normalizedTicketOrganizationValues(values: unknown) {
|
|
if (!Array.isArray(values)) {
|
|
return defaultTicketViewSettings.organizationValues;
|
|
}
|
|
|
|
return Array.from(new Set(values.filter((value): value is string => typeof value === "string" && value.trim() !== "")));
|
|
}
|
|
|
|
function legacyTicketFilterValues(parsed: Partial<AnalysisTicketViewSettings> & Record<string, unknown>) {
|
|
const filters: string[] = [];
|
|
|
|
if (parsed.workType === "support" || parsed.workType === "consulting") {
|
|
filters.push(`workType:${parsed.workType}`);
|
|
}
|
|
|
|
if (parsed.status === "open" || parsed.status === "evaluated") {
|
|
filters.push(`status:${parsed.status}`);
|
|
}
|
|
|
|
return filters;
|
|
}
|
|
|
|
function legacyTicketOrganizationValues(parsed: Partial<AnalysisTicketViewSettings> & Record<string, unknown>) {
|
|
if (Array.isArray(parsed.organizationValues)) {
|
|
return normalizedTicketOrganizationValues(parsed.organizationValues);
|
|
}
|
|
|
|
if (typeof parsed.organization === "string" && parsed.organization !== "all") {
|
|
return [parsed.organization];
|
|
}
|
|
|
|
return defaultTicketViewSettings.organizationValues;
|
|
}
|
|
|
|
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>;
|
|
const filterValues = Array.isArray(parsed.filterValues)
|
|
? normalizedTicketFilterValues(parsed.filterValues)
|
|
: legacyTicketFilterValues(parsed as Partial<AnalysisTicketViewSettings> & Record<string, unknown>);
|
|
|
|
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,
|
|
filterValues,
|
|
organizationValues: legacyTicketOrganizationValues(parsed as Partial<AnalysisTicketViewSettings> & Record<string, unknown>)
|
|
};
|
|
} 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 formatSignedMinutes(minutes: number) {
|
|
if (minutes === 0) {
|
|
return formatMinutes(0);
|
|
}
|
|
|
|
return `${minutes > 0 ? "+" : "-"}${formatMinutes(Math.abs(minutes))}`;
|
|
}
|
|
|
|
function teamspaceMonthDeltaMinutes(ticket: TicketSummary) {
|
|
const actuals = ticket.teamspace_month_actuals ?? [];
|
|
|
|
if (actuals.length === 0) {
|
|
return 0;
|
|
}
|
|
|
|
const tsMinutes = ticket.teamspace_month_billable_minutes ?? actuals.reduce((sum, actual) => sum + actual.teamspace_billable_minutes, 0);
|
|
return ticket.teamspace_month_delta_minutes ?? tsMinutes - ticket.crm_billed_minutes;
|
|
}
|
|
|
|
function TeamspaceDeviationIcon({ ticket }: { ticket: TicketSummary }) {
|
|
const deltaMinutes = teamspaceMonthDeltaMinutes(ticket);
|
|
|
|
if (deltaMinutes === 0) {
|
|
return <span className="inline-block size-4" aria-hidden="true" />;
|
|
}
|
|
|
|
const title = deltaMinutes > 0 ? `${formatMinutes(deltaMinutes)} fehlt in TT` : `${formatMinutes(Math.abs(deltaMinutes))} fehlt in TS`;
|
|
|
|
return (
|
|
<span title={`${formatSignedMinutes(deltaMinutes)} · ${title}`} aria-label={`Teamspace-Abweichung: ${title}`}>
|
|
<AlertCircle className="mx-auto size-4 text-amber-500" />
|
|
</span>
|
|
);
|
|
}
|
|
|
|
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 groupedTicketFilterMatch(filters: string[], groupForFilter: (filter: string) => string, matchesFilter: (filter: string) => boolean) {
|
|
if (filters.length === 0) {
|
|
return true;
|
|
}
|
|
|
|
const groups = new Map<string, string[]>();
|
|
|
|
for (const filter of filters) {
|
|
const group = groupForFilter(filter);
|
|
groups.set(group, [...(groups.get(group) ?? []), filter]);
|
|
}
|
|
|
|
return Array.from(groups.values()).every((groupFilters) => groupFilters.some((filter) => matchesFilter(filter)));
|
|
}
|
|
|
|
function ticketFilterGroup(filter: string) {
|
|
if (filter.startsWith("workType:")) {
|
|
return "workType";
|
|
}
|
|
|
|
if (filter.startsWith("status:")) {
|
|
return "status";
|
|
}
|
|
|
|
return "misc";
|
|
}
|
|
|
|
function matchesSingleTicketFilter(ticket: TicketSummary, filter: string) {
|
|
if (filter.startsWith("workType:")) {
|
|
const workType = filter.slice("workType:".length);
|
|
|
|
return ticket.work_type === workType;
|
|
}
|
|
|
|
if (filter.startsWith("status:")) {
|
|
const status = filter.slice("status:".length);
|
|
|
|
return ticketStatusKey(ticket) === status;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
function matchesTicketFilters(ticket: TicketSummary, filters: string[], organizationValues: string[]) {
|
|
if (organizationValues.length > 0 && !organizationValues.includes(organizationKey(ticket))) {
|
|
return false;
|
|
}
|
|
|
|
return groupedTicketFilterMatch(filters, ticketFilterGroup, (filter) => matchesSingleTicketFilter(ticket, filter));
|
|
}
|
|
|
|
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);
|
|
});
|
|
}
|
|
|
|
export function AnalysisPage({ currentUser, 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 [exportingMonth, setExportingMonth] = 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("Tickets konnten 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")}`);
|
|
}
|
|
|
|
}
|
|
|
|
async function exportMonth() {
|
|
if (periodType !== "month") {
|
|
return;
|
|
}
|
|
|
|
setExportingMonth(true);
|
|
|
|
try {
|
|
const { blob, filename } = await downloadMonthExport(period);
|
|
const url = window.URL.createObjectURL(blob);
|
|
const link = document.createElement("a");
|
|
link.href = url;
|
|
link.download = filename;
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
link.remove();
|
|
window.URL.revokeObjectURL(url);
|
|
toast.success("Monatsexport erstellt", {
|
|
description: filename
|
|
});
|
|
} catch (error) {
|
|
toast.error("Monatsexport fehlgeschlagen", {
|
|
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
|
});
|
|
} finally {
|
|
setExportingMonth(false);
|
|
}
|
|
}
|
|
|
|
const totals = overview?.totals;
|
|
const teamspaceTimeTrackingEnabled = currentUser.teamspace_time_tracking_enabled ?? true;
|
|
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: teamspaceTimeTrackingEnabled ? `Teamspace ${formatMinutes(totals?.crmBilledMinutes ?? 0)}` : "Ist aus Teamspace",
|
|
icon: Clock3
|
|
},
|
|
{ label: "Abrechenbar", value: formatMinutes(totals?.billableMinutes ?? 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<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) ||
|
|
(ticket.title ?? "").toLocaleLowerCase("de").includes(normalizedQuery) ||
|
|
organizationLabel(ticket).toLocaleLowerCase("de").includes(normalizedQuery);
|
|
|
|
return matchesQuery && matchesTicketFilters(ticket, ticketViewSettings.filterValues, ticketViewSettings.organizationValues);
|
|
});
|
|
|
|
return sortTickets(filtered, ticketViewSettings.sortBy, ticketViewSettings.sortDirection);
|
|
}, [tickets, ticketViewSettings]);
|
|
const ticketSections = useMemo(() => groupTicketSections(visibleTickets, ticketViewSettings.groupBy), [visibleTickets, ticketViewSettings.groupBy]);
|
|
const activeFilterCount = [
|
|
ticketViewSettings.query.trim() !== "",
|
|
JSON.stringify(ticketViewSettings.filterValues) !== JSON.stringify(defaultTicketViewSettings.filterValues),
|
|
ticketViewSettings.organizationValues.length > 0,
|
|
ticketViewSettings.groupBy !== defaultTicketViewSettings.groupBy,
|
|
ticketViewSettings.sortBy !== defaultTicketViewSettings.sortBy,
|
|
ticketViewSettings.sortDirection !== defaultTicketViewSettings.sortDirection
|
|
].filter(Boolean).length;
|
|
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">Tickets</h2>
|
|
<p className="text-sm text-muted-foreground">
|
|
{periodType === "month" ? "Tickets prüfen, Sessions bewerten und Zeiten kontrollieren." : "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)}
|
|
/>
|
|
<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>
|
|
{periodType === "month" ? (
|
|
<Button type="button" variant="secondary" className="sm:col-span-2" disabled={exportingMonth} onClick={() => void exportMonth()}>
|
|
<Download className="size-4" />
|
|
{exportingMonth ? "Exportiert..." : "Monat exportieren"}
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
|
|
{periodType === "month" && overview?.closed ? (
|
|
<Alert variant="success" className="py-3 text-sm">
|
|
Dieser Monat wurde am {formatDateTime(overview.closedAt!)} abgeschlossen. Bewertungen und Änderungen sind geschützt, bis der Monat im Monatsabschluss wieder geöffnet wird.
|
|
</Alert>
|
|
) : null}
|
|
|
|
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-5">
|
|
{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="p-4">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div>
|
|
<CardTitle className="flex items-center gap-2 text-base">
|
|
<SlidersHorizontal className="size-4 text-muted-foreground" />
|
|
Filter und Sortierung
|
|
{activeFilterCount > 0 ? <Badge variant="secondary">{activeFilterCount}</Badge> : null}
|
|
</CardTitle>
|
|
<CardDescription>{visibleTickets.length} von {tickets.length} Ticket(s) sichtbar.</CardDescription>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<HelpLink anchor="auswertung" label="Hilfe zu Filtern und Sortierung" />
|
|
<Button type="button" size="sm" variant="ghost" disabled={activeFilterCount === 0} onClick={() => setTicketViewSettings(defaultTicketViewSettings)}>
|
|
<RotateCcw className="size-4" />
|
|
Zurücksetzen
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="grid gap-3 px-4 pb-4 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-5">
|
|
<div className="space-y-1.5">
|
|
<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, Titel, Organisation..."
|
|
onChange={(event) => {
|
|
const query = event.currentTarget.value;
|
|
setTicketViewSettings((current) => ({ ...current, query }));
|
|
}}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<label className="text-xs font-medium text-muted-foreground">Filter</label>
|
|
<MultiFilterDropdown
|
|
values={ticketViewSettings.filterValues}
|
|
options={ticketStaticFilterOptions}
|
|
onChange={(filterValues) => setTicketViewSettings((current) => ({ ...current, filterValues }))}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<label className="text-xs font-medium text-muted-foreground">Organisationen</label>
|
|
<MultiFilterDropdown
|
|
values={ticketViewSettings.organizationValues}
|
|
options={organizationOptions}
|
|
onChange={(organizationValues) => setTicketViewSettings((current) => ({ ...current, organizationValues }))}
|
|
placeholder="Alle Org."
|
|
countLabel="Org."
|
|
emptyLabel="Keine Org."
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<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.5">
|
|
<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.5">
|
|
<label className="text-xs font-medium text-muted-foreground">Richtung</label>
|
|
<div className="grid grid-cols-2 rounded-md border bg-background p-0.5">
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant={ticketViewSettings.sortDirection === "asc" ? "secondary" : "ghost"}
|
|
className="h-7 px-2"
|
|
onClick={() => setTicketViewSettings((current) => ({ ...current, sortDirection: "asc" }))}
|
|
>
|
|
<ArrowUp className="size-3.5" />
|
|
Auf
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant={ticketViewSettings.sortDirection === "desc" ? "secondary" : "ghost"}
|
|
className="h-7 px-2"
|
|
onClick={() => setTicketViewSettings((current) => ({ ...current, sortDirection: "desc" }))}
|
|
>
|
|
<ArrowDown className="size-3.5" />
|
|
Ab
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader className="gap-3 p-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
|
|
<div>
|
|
<CardTitle>Sessions im Zeitraum</CardTitle>
|
|
<CardDescription>
|
|
{periodType === "month" ? "Sessions prüfen, bewerten und bei Bedarf Tickets öffnen." : "Tagesansicht ohne eigenen Abschluss."}
|
|
</CardDescription>
|
|
</div>
|
|
<HelpLink anchor="auswertung" label="Hilfe zu Sessions im Zeitraum" />
|
|
</CardHeader>
|
|
<CardContent className="px-4 pb-4">
|
|
<div className="hidden md:block">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Ticket</TableHead>
|
|
<TableHead className="w-10 text-center">
|
|
<span className="sr-only">Teamspace-Abweichung</span>
|
|
</TableHead>
|
|
<TableHead>Organisation</TableHead>
|
|
<TableHead>Art</TableHead>
|
|
<TableHead>Sessions</TableHead>
|
|
<TableHead>Zeit abgerechnet</TableHead>
|
|
<TableHead>Status</TableHead>
|
|
<TableHead />
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{ticketSections.map((section, sectionIndex) => (
|
|
<Fragment key={section.key}>
|
|
{ticketViewSettings.groupBy !== "none" ? (
|
|
<TableRow className={`${sectionIndex > 0 ? "border-t-[12px] border-t-background" : ""} border-b-2 border-border bg-muted/70 hover:bg-muted/70`}>
|
|
<TableCell colSpan={8} className="py-0">
|
|
<div className="flex flex-wrap items-center justify-between gap-3 rounded-md border border-border/80 bg-card px-3 py-2 shadow-sm">
|
|
<div className="flex min-w-0 items-center gap-2">
|
|
<span className="rounded-sm bg-primary px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-primary-foreground">Gruppe</span>
|
|
<span className="truncate text-base font-semibold">{section.label}</span>
|
|
</div>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<Badge variant="outline">{section.ticketCount} Session(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>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
) : null}
|
|
{section.tickets.map((ticket) => (
|
|
<TableRow key={ticket.id}>
|
|
<TableCell>
|
|
<div className="flex min-w-0 items-center justify-between gap-2">
|
|
<div className="flex min-w-0 items-center gap-1">
|
|
<span className="truncate font-semibold">{ticket.ticket_number}</span>
|
|
<CopyTicketButton ticketNumber={ticket.ticket_number} />
|
|
</div>
|
|
{ticket.project_id ? <Badge variant="outline" className="shrink-0">Projekt</Badge> : null}
|
|
</div>
|
|
{ticket.title ? <p className="max-w-[220px] truncate text-xs text-muted-foreground">{ticket.title}</p> : null}
|
|
</TableCell>
|
|
<TableCell className="text-center">
|
|
<TeamspaceDeviationIcon ticket={ticket} />
|
|
</TableCell>
|
|
<TableCell>{ticket.customer_name ?? "-"}</TableCell>
|
|
<TableCell>{workTypeLabel(ticket.work_type)}</TableCell>
|
|
<TableCell>{ticket.session_count}</TableCell>
|
|
<TableCell>{formatMinutes(ticket.total_minutes)}</TableCell>
|
|
<TableCell>
|
|
<TicketStatus ticket={ticket} />
|
|
</TableCell>
|
|
<TableCell className="text-right">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => onNavigate(`/tickets/${ticket.id}?month=${periodType === "day" ? period.slice(0, 7) : period}`)}
|
|
>
|
|
Öffnen
|
|
<ExternalLink className="size-4" />
|
|
</Button>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</Fragment>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
|
|
<div className="space-y-2 md:hidden">
|
|
{ticketSections.map((section, sectionIndex) => (
|
|
<div key={section.key} className={`space-y-2 ${ticketViewSettings.groupBy !== "none" && sectionIndex > 0 ? "pt-3" : ""}`}>
|
|
{ticketViewSettings.groupBy !== "none" ? (
|
|
<div className="rounded-md border-2 border-border bg-card px-3 py-2 shadow-sm">
|
|
<div className="mb-2 flex min-w-0 items-center gap-2">
|
|
<span className="rounded-sm bg-primary px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-primary-foreground">Gruppe</span>
|
|
<span className="truncate text-base font-semibold">{section.label}</span>
|
|
</div>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<Badge variant="outline">{section.ticketCount} Session(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>
|
|
</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 justify-between gap-2">
|
|
<div className="flex min-w-0 items-center gap-1">
|
|
<p className="truncate font-semibold">{ticket.ticket_number}</p>
|
|
<CopyTicketButton ticketNumber={ticket.ticket_number} />
|
|
</div>
|
|
{ticket.project_id ? <Badge variant="outline" className="shrink-0">Projekt</Badge> : null}
|
|
</div>
|
|
{ticket.title ? <p className="truncate text-xs text-muted-foreground">{ticket.title}</p> : null}
|
|
<p className="text-sm text-muted-foreground">{ticket.customer_name ?? "Keine Organisation"} · {workTypeLabel(ticket.work_type)} · {ticket.session_count} Session(s)</p>
|
|
</div>
|
|
<div className="flex shrink-0 items-center gap-2">
|
|
<TeamspaceDeviationIcon ticket={ticket} />
|
|
<TicketStatus ticket={ticket} />
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-2 text-sm">
|
|
<div className="rounded-md bg-muted/50 p-2">
|
|
<p className="text-muted-foreground">Abr.</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">Abrechenbar</p>
|
|
<p className="font-medium">{ticket.billable_count}</p>
|
|
</div>
|
|
</div>
|
|
<Button
|
|
className="w-full"
|
|
size="sm"
|
|
variant="secondary"
|
|
onClick={() => onNavigate(`/tickets/${ticket.id}?month=${periodType === "day" ? period.slice(0, 7) : period}`)}
|
|
>
|
|
Ö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 Sessions passend zu den aktuellen Filtern.</p>
|
|
) : null}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|