Update
This commit is contained in:
1 parent
3d9674bd5a
commit
d1da86beab
20 files changed
+6109
-923
No files matched your search
@@ -19,13 +19,6 @@ 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";
|
||||
@@ -224,82 +217,6 @@ function groupTicketSections(tickets: TicketSummary[], groupBy: TicketGroupBy):
|
||||
});
|
||||
}
|
||||
|
||||
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());
|
||||
@@ -325,7 +242,7 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
|
||||
}
|
||||
} catch (error) {
|
||||
if (requestId === loadRequestId.current) {
|
||||
toast.error("Auswertung konnte nicht geladen werden", {
|
||||
toast.error("Tickets konnten nicht geladen werden", {
|
||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||
});
|
||||
}
|
||||
@@ -458,37 +375,11 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
|
||||
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>
|
||||
<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>
|
||||
@@ -557,95 +448,6 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="gap-3 p-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
|
||||
<div>
|
||||
<CardTitle>Abgerechneter Aufwand</CardTitle>
|
||||
<CardDescription>
|
||||
{periodType === "month" ? "Abrechenbare Zeit pro Tag im ausgewählten Monat." : "Abrechenbare Zeit pro Stunde am ausgewählten Tag."}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline">{formatMinutes(totals?.minutes ?? 0)}</Badge>
|
||||
<HelpLink anchor="auswertung" label="Hilfe zur Auswertung" />
|
||||
</div>
|
||||
</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" />
|
||||
Abgerechnet
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="h-0.5 w-4 bg-foreground" />
|
||||
Verlauf
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="gap-3 p-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
|
||||
<div>
|
||||
@@ -690,7 +492,10 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
|
||||
className="h-8"
|
||||
value={ticketViewSettings.query}
|
||||
placeholder="Ticket oder Organisation"
|
||||
onChange={(event) => setTicketViewSettings((current) => ({ ...current, query: event.currentTarget.value }))}
|
||||
onChange={(event) => {
|
||||
const query = event.currentTarget.value;
|
||||
setTicketViewSettings((current) => ({ ...current, query }));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -823,11 +628,14 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
|
||||
{section.tickets.map((ticket) => (
|
||||
<TableRow key={ticket.id}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="font-semibold">{ticket.title || ticket.ticket_number}</span>
|
||||
<CopyTicketButton ticketNumber={ticket.ticket_number} />
|
||||
<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="text-xs text-muted-foreground">{ticket.ticket_number}</p> : null}
|
||||
{ticket.title ? <p className="max-w-[220px] truncate text-xs text-muted-foreground">{ticket.title}</p> : null}
|
||||
</TableCell>
|
||||
<TableCell>{ticket.customer_name ?? "-"}</TableCell>
|
||||
<TableCell>{workTypeLabel(ticket.work_type)}</TableCell>
|
||||
@@ -875,11 +683,14 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
|
||||
<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.title || ticket.ticket_number}</p>
|
||||
<CopyTicketButton ticketNumber={ticket.ticket_number} />
|
||||
<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="text-xs text-muted-foreground">{ticket.ticket_number}</p> : null}
|
||||
{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>
|
||||
<TicketStatus ticket={ticket} />
|
||||
|
||||
Reference in new issue
Block a user