This commit is contained in:
michael committed 2026-09-08 16:48:28 +02:00
1 parent 3d9674bd5a
commit d1da86beab
20 files changed
+6109 -923

No files matched your search

+20 -209
View File
@@ -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} />
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -46,7 +46,7 @@ export function FaqPage() {
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
<div>
<h2 className="text-2xl font-semibold tracking-normal">FAQ und Anleitung</h2>
<p className="text-sm text-muted-foreground">Ausführliche Anleitung für Timer, Auswertung, Teamspace, fixe Abrechnungen, Monatsabschluss und Adminfunktionen.</p>
<p className="text-sm text-muted-foreground">Ausführliche Anleitung für Timer, Tickets, Teamspace, fixe Abrechnungen, Monatsabschluss und Adminfunktionen.</p>
</div>
<Badge variant="outline">{helpSections.length} Abschnitt(e)</Badge>
</div>
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -204,7 +204,7 @@ export function RecurringBillingsPage() {
setRecurringForm(emptyRecurringForm());
setEditingBillingId(null);
toast.success(editingBillingId ? "Fixe Abrechnung gespeichert" : "Fixe Abrechnung angelegt", {
description: "Die Sessions erscheinen automatisch in der Auswertung, sobald du den Zeitraum öffnest. Nicht mehr gültige erzeugte Sessions werden entfernt."
description: "Die Sessions erscheinen automatisch in Tickets, sobald du den Zeitraum öffnest. Nicht mehr gültige erzeugte Sessions werden entfernt."
});
} catch (error) {
toast.error("Fixe Abrechnung konnte nicht angelegt werden", {
@@ -454,7 +454,7 @@ export function RecurringBillingsPage() {
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle>Regeln</CardTitle>
<CardDescription>Erzeugte Einträge können in der Auswertung verschoben oder gelöscht werden.</CardDescription>
<CardDescription>Erzeugte Einträge können in Tickets verschoben oder gelöscht werden.</CardDescription>
</div>
<HelpLink anchor="fixe-abrechnungen" label="Hilfe zu erzeugten fixen Sessions" />
</div>
+1 -1
View File
@@ -712,7 +712,7 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
</div>
<div className="flex items-center gap-2">
<Button variant="secondary" size="sm" onClick={() => onNavigate("/analysis")}>
Auswertung öffnen
Tickets öffnen
<ExternalLink className="size-4" />
</Button>
<HelpLink anchor="statistik-hotspots" label="Hilfe zu Ticket-Hotspots" />
File diff suppressed because it is too large. Load diff
+69 -6
View File
@@ -10,10 +10,11 @@ import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { HelpLink } from "@/components/HelpLink";
import { OrganizationSelect } from "@/components/OrganizationSelect";
import { createSession, lookupTicket } from "../api";
import { createSession, getRecurringBillings, lookupTicket } from "../api";
import { formatTimer } from "../format";
import { isRecurringBillingTicketNumber, ticketHasRecurringBilling } from "../recurring-billings";
import { activeElapsedMs, pauseEntry, resumeEntry, ticketPattern, type TimerEntry } from "../timers";
import type { TicketMeta, WorkType } from "../types";
import type { RecurringBilling, TicketMeta, WorkType } from "../types";
type ManualSessionFormState = {
ticketNumber: string;
@@ -75,14 +76,41 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
const [savingManualSession, setSavingManualSession] = useState(false);
const [manualLookupStatus, setManualLookupStatus] = useState<"idle" | "loading" | "found" | "not-found">("idle");
const [manualLookupTicket, setManualLookupTicket] = useState<TicketMeta | null>(null);
const [recurringBillings, setRecurringBillings] = useState<RecurringBilling[]>([]);
const selectedTimer = timers.find((timer) => timer.id === selectedTimerId) ?? timers[0] ?? null;
const elapsedSeconds = useMemo(() => Math.floor(activeElapsedMs(selectedTimer, tick) / 1000), [selectedTimer, tick]);
const isValidTicket = ticketPattern.test(ticketNumber);
const isManualTicketValid = ticketPattern.test(manualSession.ticketNumber.trim());
const manualTicketDefaultsLocked = Boolean(manualLookupTicket?.organization_id && manualLookupTicket.work_type);
const manualTicketIsRecurringBilling =
ticketHasRecurringBilling(manualLookupTicket, recurringBillings) || isRecurringBillingTicketNumber(manualSession.ticketNumber.trim(), recurringBillings);
const runningTimer = timers.find((timer) => timer.phase === "running") ?? null;
useEffect(() => {
let cancelled = false;
async function loadRecurringBillings() {
try {
const result = await getRecurringBillings();
if (!cancelled) {
setRecurringBillings(result.recurringBillings);
}
} catch {
if (!cancelled) {
setRecurringBillings([]);
}
}
}
void loadRecurringBillings();
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
const normalizedTicketNumber = manualSession.ticketNumber.trim();
@@ -104,7 +132,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
const timeout = window.setTimeout(async () => {
try {
const result = await lookupTicket(normalizedTicketNumber);
const result = await lookupTicket(normalizedTicketNumber);
if (cancelled) {
return;
@@ -149,6 +177,16 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
return;
}
const ticketResult = await lookupTicket(ticketNumber).catch(() => ({ ticket: null }));
if (ticketHasRecurringBilling(ticketResult.ticket, recurringBillings) || isRecurringBillingTicketNumber(ticketNumber, recurringBillings)) {
setTimers((current) => current.filter((timer) => timer.ticketNumber !== ticketNumber));
toast.error("Fixe Abrechnung", {
description: "Für fixe Abrechnungen dürfen keine manuellen Sessions gestartet werden. Bitte die Definition bearbeiten."
});
return;
}
if (timers.some((timer) => timer.ticketNumber === ticketNumber)) {
toast.error("Timer existiert bereits", {
description: "Für dieses Ticket läuft oder pausiert schon ein Timer."
@@ -157,7 +195,6 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
}
const now = Date.now();
const ticketResult = await lookupTicket(ticketNumber).catch(() => ({ ticket: null }));
const newTimer: TimerEntry = {
id: crypto.randomUUID(),
ticketNumber,
@@ -221,6 +258,14 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
const result = await lookupTicket(timer.ticketNumber);
const ticket = result.ticket;
if (ticketHasRecurringBilling(ticket, recurringBillings) || isRecurringBillingTicketNumber(timer.ticketNumber, recurringBillings)) {
setTimers((current) => current.filter((entry) => entry.id !== timer.id));
toast.error("Fixe Abrechnung", {
description: "Für fixe Abrechnungen dürfen keine manuellen Sessions gespeichert werden. Bitte die Definition bearbeiten."
});
return;
}
setOrganizationId(ticket?.organization_id ?? "");
setOrganizationName(ticket?.organization_name ?? null);
setWorkType(ticket?.work_type ?? "support");
@@ -250,6 +295,14 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
return;
}
if (ticketHasRecurringBilling(finished.ticket, recurringBillings) || isRecurringBillingTicketNumber(finished.ticketNumber, recurringBillings)) {
toast.error("Fixe Abrechnung", {
description: "Für fixe Abrechnungen dürfen keine manuellen Sessions gespeichert werden. Bitte die Definition bearbeiten."
});
setFinished(null);
return;
}
const existingTicketHasDefaults = Boolean(finished.ticket?.organization_id && finished.ticket?.work_type);
const effectiveOrganizationId = existingTicketHasDefaults ? finished.ticket!.organization_id! : organizationId;
const effectiveWorkType = existingTicketHasDefaults ? finished.ticket!.work_type! : workType;
@@ -307,6 +360,13 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
return;
}
if (manualTicketIsRecurringBilling) {
toast.error("Fixe Abrechnung", {
description: "Für fixe Abrechnungen dürfen keine Sessions nachgetragen werden. Bitte die Definition bearbeiten."
});
return;
}
const startedAt = new Date(`${manualSession.day}T${manualSession.startTime}:00`);
const endedAt = new Date(`${manualSession.day}T${manualSession.endTime}:00`);
@@ -335,7 +395,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
durationSeconds
});
toast.success("Session nachgetragen", {
description: manualLookupTicket?.is_project ? "Der Eintrag wurde dem Projekt zugeordnet." : "Der Eintrag ist in deiner Auswertung offen."
description: manualLookupTicket?.is_project ? "Der Eintrag wurde dem Projekt zugeordnet." : "Der Eintrag ist in deiner Ticketliste offen."
});
setManualSession({
...emptyManualSession(),
@@ -488,6 +548,9 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
{manualLookupStatus === "found" && !manualTicketDefaultsLocked ? (
<p className="text-xs text-muted-foreground">Ticket gefunden, aber Stammdaten sind unvollständig. Bitte ergänzen.</p>
) : null}
{manualTicketIsRecurringBilling ? (
<p className="text-xs text-destructive">Fixe Abrechnung: Sessions kommen ausschließlich aus der Definition.</p>
) : null}
{manualLookupStatus === "not-found" ? <p className="text-xs text-muted-foreground">Neues Ticket. Organisation und Art bitte wählen.</p> : null}
</div>
<div className="space-y-2">
@@ -562,7 +625,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
required
/>
</div>
<Button type="submit" disabled={savingManualSession}>
<Button type="submit" disabled={savingManualSession || manualTicketIsRecurringBilling}>
<Upload className="size-4" />
{savingManualSession ? "Speichert..." : "Nachtragen"}
</Button>
File diff suppressed because it is too large. Load diff