Files
tickettracker/frontend/src/App.tsx
T
2026-08-05 15:21:55 +02:00

725 lines
25 KiB
TypeScript

import {
BarChart3,
CheckCircle2,
ClipboardCheck,
Command,
ChartNoAxesCombined,
LayoutGrid,
LogOut,
Moon,
Pause,
Play,
Plus,
Repeat,
Search,
Shield,
Sun,
Timer,
UserCog,
UserCircle,
} from "lucide-react";
import { FormEvent, MouseEvent, useEffect, useMemo, useState } from "react";
import { useShallow } from "zustand/react/shallow";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Separator } from "@/components/ui/separator";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInset,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarProvider,
SidebarTrigger,
} from "@/components/ui/sidebar";
import { usePreferencesStore } from "@/stores/preferences/preferences-provider";
import { cn } from "./lib/utils";
import { ApiError, getCurrentUser, logout, lookupTicket } from "./api";
import { formatTimer } from "./format";
import { activeElapsedMs, pauseEntry, readStoredTimers, resumeEntry, storageKeyForUser, ticketPattern, type TimerEntry } from "./timers";
import { AdminUsersPage } from "./views/AdminUsersPage";
import { AnalysisPage } from "./views/AnalysisPage";
import { LoginPage } from "./views/LoginPage";
import { MonthlyClosePage } from "./views/MonthlyClosePage";
import { ProfilePage } from "./views/ProfilePage";
import { RecurringBillingsPage } from "./views/RecurringBillingsPage";
import { StatisticsPage } from "./views/StatisticsPage";
import { TicketDetailPage } from "./views/TicketDetailPage";
import { TimerPage } from "./views/TimerPage";
import type { AuthUser, PeriodType } from "./types";
import type { Dispatch, SetStateAction } from "react";
function routeFromPath(pathname: string) {
const periodTicketMatch = pathname.match(/^\/analysis\/(month|day)\/([^/]+)\/tickets\/(\d+)$/);
if (periodTicketMatch) {
return {
page: "ticket" as const,
periodType: periodTicketMatch[1] as PeriodType,
period: periodTicketMatch[2],
ticketId: periodTicketMatch[3],
};
}
const legacyTicketMatch = pathname.match(/^\/analysis\/(\d{4}-\d{2})\/tickets\/(\d+)$/);
if (legacyTicketMatch) {
return {
page: "ticket" as const,
periodType: "month" as const,
period: legacyTicketMatch[1],
ticketId: legacyTicketMatch[2],
};
}
if (pathname.startsWith("/analysis")) {
return { page: "analysis" as const };
}
if (pathname.startsWith("/statistics")) {
return { page: "statistics" as const };
}
if (pathname.startsWith("/monthly-close")) {
return { page: "monthly-close" as const };
}
if (pathname.startsWith("/recurring")) {
return { page: "recurring" as const };
}
if (pathname.startsWith("/admin/users")) {
return { page: "admin-users" as const };
}
if (pathname.startsWith("/profile")) {
return { page: "profile" as const };
}
return { page: "timer" as const };
}
function useAppRouter() {
const [path, setPath] = useState("/timer");
useEffect(() => {
const update = () => setPath(window.location.pathname);
update();
window.addEventListener("popstate", update);
return () => window.removeEventListener("popstate", update);
}, []);
function navigate(to: string) {
window.history.pushState({}, "", to);
setPath(window.location.pathname);
}
function navHandler(to: string) {
return (event: MouseEvent<HTMLAnchorElement>) => {
event.preventDefault();
navigate(to);
};
}
return {
path,
route: useMemo(() => routeFromPath(path), [path]),
navigate,
navHandler,
};
}
function ThemeSwitcher() {
const { themeMode, setPreference } = usePreferencesStore(
useShallow((state) => ({
themeMode: state.values.theme_mode,
setPreference: state.setPreference,
})),
);
function cycleTheme() {
setPreference("theme_mode", themeMode === "dark" ? "light" : "dark");
}
return (
<Button variant="default" size="icon" aria-label="Farbschema wechseln" onClick={cycleTheme}>
<Sun className="hidden dark:block" />
<Moon className="block dark:hidden" />
</Button>
);
}
type QuickTimerStarterProps = {
className?: string;
inputId: string;
onStartTimer: (ticketNumber: string) => Promise<boolean>;
};
function QuickTimerStarter({ className, inputId, onStartTimer }: QuickTimerStarterProps) {
const [ticketNumber, setTicketNumber] = useState("");
const trimmedTicketNumber = ticketNumber.trim();
const hasInvalidTicket = trimmedTicketNumber.length > 0 && !ticketPattern.test(trimmedTicketNumber);
async function submit(event: FormEvent) {
event.preventDefault();
if (await onStartTimer(trimmedTicketNumber)) {
setTicketNumber("");
}
}
return (
<form className={cn("flex min-w-0 items-center gap-2", className)} onSubmit={submit}>
<label className="sr-only" htmlFor={inputId}>
Ticketnummer fuer neuen Timer
</label>
<Input
id={inputId}
className={cn("h-8 min-w-0 bg-background", hasInvalidTicket && "border-destructive focus-visible:border-destructive")}
placeholder="Ticket#123456"
value={ticketNumber}
onChange={(event) => setTicketNumber(event.currentTarget.value)}
/>
<Button type="submit" size="sm" className="h-8 shrink-0">
<Plus className="size-3.5" />
<span className="hidden sm:inline">Timer starten</span>
<span className="sm:hidden">Start</span>
</Button>
</form>
);
}
type SidebarTimersProps = {
timers: TimerEntry[];
selectedTimerId: string | null;
setSelectedTimerId: Dispatch<SetStateAction<string | null>>;
setTimers: Dispatch<SetStateAction<TimerEntry[]>>;
tick: number;
onOpenTimer: () => void;
};
function SidebarTimers({ timers, selectedTimerId, setSelectedTimerId, setTimers, tick, onOpenTimer }: SidebarTimersProps) {
const sortedTimers = useMemo(
() =>
[...timers].sort((first, second) => {
if (first.phase === second.phase) {
return first.ticketNumber.localeCompare(second.ticketNumber);
}
return first.phase === "running" ? -1 : 1;
}),
[timers],
);
const runningTimer = timers.find((timer) => timer.phase === "running") ?? null;
function toggleTimer(timerId: string) {
const timer = timers.find((entry) => entry.id === timerId);
if (!timer) {
return;
}
const now = Date.now();
setSelectedTimerId(timerId);
setTimers((current) =>
current.map((entry) => {
if (entry.id !== timerId) {
return pauseEntry(entry, now);
}
return timer.phase === "running" ? pauseEntry(entry, now) : resumeEntry(entry, now);
}),
);
}
return (
<SidebarGroup className="pt-0 group-data-[collapsible=icon]:hidden">
<SidebarGroupLabel className="h-6 px-2 text-xs">Timer</SidebarGroupLabel>
<SidebarGroupContent>
<div className="mx-2 rounded-md border bg-card/70 p-2 shadow-xs">
<div className="mb-1 flex items-center justify-between gap-2">
<span className="truncate text-xs font-medium text-muted-foreground">
{runningTimer ? "Aktuell aktiv" : timers.length > 0 ? "Bereit" : "Kein Timer"}
</span>
{runningTimer ? <span className="size-2 rounded-full bg-emerald-500" aria-hidden="true" /> : null}
</div>
{sortedTimers.length === 0 ? (
<p className="px-1 py-1 text-xs text-muted-foreground">Noch keine Timer gestartet.</p>
) : (
<div className="max-h-52 space-y-1 overflow-y-auto pr-1">
{sortedTimers.map((timer) => {
const elapsed = Math.floor(activeElapsedMs(timer, tick) / 1000);
const isSelected = selectedTimerId === timer.id;
const isRunning = timer.phase === "running";
return (
<div
key={timer.id}
className={cn(
"flex items-center gap-1.5 rounded-md border px-2 py-1.5",
isSelected ? "border-primary/55 bg-accent/35" : "border-transparent bg-background/60",
)}
>
<button
type="button"
className="min-w-0 flex-1 text-left"
onClick={() => {
setSelectedTimerId(timer.id);
onOpenTimer();
}}
>
<span className="flex min-w-0 items-center gap-1.5">
<span className={cn("size-1.5 rounded-full", isRunning ? "bg-emerald-500" : "bg-muted-foreground/50")} />
<span className="truncate text-xs font-medium">{timer.ticketNumber}</span>
</span>
{timer.organizationName ? (
<span className="block truncate text-[11px] leading-4 text-muted-foreground">
{timer.organizationName}
</span>
) : null}
<span className="block font-mono text-[11px] font-semibold leading-4 tracking-normal text-muted-foreground">
{formatTimer(elapsed)}
</span>
</button>
<Button
type="button"
size="icon"
variant={isRunning ? "secondary" : "ghost"}
className="size-7 shrink-0"
onClick={() => toggleTimer(timer.id)}
aria-label={isRunning ? "Timer pausieren" : "Timer aktivieren"}
>
{isRunning ? <Pause className="size-3.5" /> : <Play className="size-3.5" />}
</Button>
</div>
);
})}
</div>
)}
</div>
</SidebarGroupContent>
</SidebarGroup>
);
}
export function App() {
const { path, route, navHandler, navigate } = useAppRouter();
const [currentUser, setCurrentUser] = useState<AuthUser | null>(null);
const [authLoading, setAuthLoading] = useState(true);
const [timers, setTimers] = useState<TimerEntry[]>([]);
const [timersLoaded, setTimersLoaded] = useState(false);
const [timerOwnerId, setTimerOwnerId] = useState<string | null>(null);
const [selectedTimerId, setSelectedTimerId] = useState<string | null>(null);
const [tick, setTick] = useState(Date.now());
useEffect(() => {
async function loadUser() {
try {
const result = await getCurrentUser();
setCurrentUser(result.user);
} catch (error) {
if (!(error instanceof ApiError && error.status === 401)) {
console.error(error);
}
setCurrentUser(null);
} finally {
setAuthLoading(false);
}
}
void loadUser();
}, []);
useEffect(() => {
if (!currentUser) {
setTimers([]);
setSelectedTimerId(null);
setTimerOwnerId(null);
setTimersLoaded(false);
return;
}
setTimers(readStoredTimers(currentUser.id));
setTimerOwnerId(currentUser.id);
setTimersLoaded(true);
}, [currentUser]);
useEffect(() => {
if (!currentUser || !timersLoaded || timerOwnerId !== currentUser.id) {
return;
}
localStorage.setItem(storageKeyForUser(currentUser.id), JSON.stringify(timers));
if (timers.length === 0) {
setSelectedTimerId(null);
return;
}
if (!selectedTimerId || !timers.some((timer) => timer.id === selectedTimerId)) {
setSelectedTimerId(timers[0].id);
}
}, [currentUser, timers, selectedTimerId, timersLoaded, timerOwnerId]);
useEffect(() => {
if (!currentUser || !timersLoaded || timerOwnerId !== currentUser.id) {
return;
}
const timersToHydrate = timers.filter((timer) => !timer.ticketLookupDone);
if (timersToHydrate.length === 0) {
return;
}
let cancelled = false;
async function hydrateTimers() {
const results = await Promise.all(
timersToHydrate.map(async (timer) => {
try {
const result = await lookupTicket(timer.ticketNumber);
return {
id: timer.id,
organizationName: result.ticket?.organization_name ?? result.ticket?.customer_name ?? null,
workType: result.ticket?.work_type ?? null
};
} catch {
return {
id: timer.id,
organizationName: null,
workType: null
};
}
})
);
if (cancelled) {
return;
}
setTimers((current) =>
current.map((timer) => {
const result = results.find((entry) => entry.id === timer.id);
if (!result) {
return timer;
}
return {
...timer,
organizationName: result.organizationName,
workType: result.workType,
ticketLookupDone: true
};
})
);
}
void hydrateTimers();
return () => {
cancelled = true;
};
}, [currentUser, timers, timersLoaded, timerOwnerId]);
useEffect(() => {
const interval = window.setInterval(() => setTick(Date.now()), 500);
return () => window.clearInterval(interval);
}, []);
async function startQuickTimer(rawTicketNumber: string) {
const ticketNumber = rawTicketNumber.trim();
if (!ticketPattern.test(ticketNumber)) {
toast.error("Ticketnummer prüfen", {
description: "Das Format muss Ticket#XXXXXX sein."
});
return false;
}
const existingTimer = timers.find((timer) => timer.ticketNumber === ticketNumber);
const now = Date.now();
if (existingTimer) {
setTimers((current) =>
current.map((timer) => (timer.id === existingTimer.id ? resumeEntry(timer, now) : pauseEntry(timer, now)))
);
setSelectedTimerId(existingTimer.id);
toast.info("Bestehender Timer aktiviert", {
description: ticketNumber
});
return true;
}
const ticketResult = await lookupTicket(ticketNumber).catch(() => ({ ticket: null }));
const newTimer: TimerEntry = {
id: crypto.randomUUID(),
ticketNumber,
organizationName: ticketResult.ticket?.organization_name ?? ticketResult.ticket?.customer_name ?? null,
workType: ticketResult.ticket?.work_type ?? null,
ticketLookupDone: true,
startedAt: now,
pausedTotalMs: 0,
pausedAt: null,
phase: "running"
};
setTimers((current) => [...current.map((timer) => pauseEntry(timer, now)), newTimer]);
setSelectedTimerId(newTimer.id);
toast.success("Timer gestartet", {
description: ticketNumber
});
return true;
}
async function handleLogout() {
await logout().catch(() => undefined);
setCurrentUser(null);
navigate("/timer");
}
if (authLoading) {
return (
<main className="grid min-h-screen place-items-center bg-background p-4 text-sm text-muted-foreground">
TicketTracker wird geladen...
</main>
);
}
if (!currentUser) {
return <LoginPage onLogin={setCurrentUser} />;
}
const navItems = [
{ href: "/timer", label: "Timer", icon: Timer, active: path.startsWith("/timer") || path === "/" },
{ href: "/analysis", label: "Auswertung", icon: BarChart3, active: path.startsWith("/analysis") },
{ href: "/monthly-close", label: "Monatsabschluss", icon: ClipboardCheck, active: path.startsWith("/monthly-close") },
{ href: "/statistics", label: "Statistiken", icon: ChartNoAxesCombined, active: path.startsWith("/statistics") },
{ href: "/recurring", label: "Fixe Abrechnung", icon: Repeat, active: path.startsWith("/recurring") },
{ href: "/profile", label: "Profil", icon: UserCog, active: path.startsWith("/profile") },
...(currentUser.role === "admin"
? [{ href: "/admin/users", label: "Benutzer", icon: Shield, active: path.startsWith("/admin/users") }]
: []),
];
const runningTimer = timers.find((timer) => timer.phase === "running") ?? null;
const runningElapsedSeconds = runningTimer ? Math.floor(activeElapsedMs(runningTimer, tick) / 1000) : 0;
return (
<SidebarProvider
defaultOpen
style={
{
"--sidebar-width": "16rem",
} as React.CSSProperties
}
>
<Sidebar variant="sidebar" collapsible="icon">
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton asChild>
<a href="/timer" onClick={navHandler("/timer")}>
<Command />
<span className="font-semibold text-base">TicketTracker</span>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarGroupContent className="flex flex-col gap-2">
<SidebarMenu>
<SidebarMenuItem className="flex items-center gap-2">
<SidebarMenuButton
asChild
tooltip="Session starten"
className="min-w-8 bg-primary text-primary-foreground duration-200 ease-linear hover:bg-primary/90 hover:text-primary-foreground active:bg-primary/90 active:text-primary-foreground"
>
<a href="/timer" onClick={navHandler("/timer")}>
<Timer />
<span>Session starten</span>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<SidebarTimers
timers={timers}
selectedTimerId={selectedTimerId}
setSelectedTimerId={setSelectedTimerId}
setTimers={setTimers}
tick={tick}
onOpenTimer={() => navigate("/timer")}
/>
<SidebarGroup>
<SidebarGroupLabel>Workflows</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{navItems.map((item) => (
<SidebarMenuItem key={item.href}>
<SidebarMenuButton asChild tooltip={item.label} isActive={item.active}>
<a href={item.href} onClick={navHandler(item.href)}>
<item.icon />
<span>{item.label}</span>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarFooter>
<div className="mx-2 rounded-lg border bg-card p-3 text-sm shadow-xs group-data-[collapsible=icon]:hidden">
<div className="mb-2 flex items-center gap-2 font-medium">
<CheckCircle2 className="size-4 text-muted-foreground" />
Abschluss
</div>
<p className="text-muted-foreground">Offene Bewertungen findest du im Monatsabschluss.</p>
</div>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton tooltip="Worklog">
<UserCircle />
<span>Worklog lokal</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
</Sidebar>
<SidebarInset
className={cn(
"[html[data-content-layout=centered]_&>*]:mx-auto",
"[html[data-content-layout=centered]_&>*]:w-full",
"[html[data-content-layout=centered]_&>*]:max-w-screen-2xl",
"[--dashboard-header-height:--spacing(12)]",
"min-w-0 overflow-x-clip",
)}
>
<header className="sticky top-0 z-50 flex h-12 shrink-0 items-center gap-2 border-b bg-background/80 backdrop-blur-md">
<div className="flex w-full items-center justify-between px-3 sm:px-5 lg:px-6">
<div className="flex min-w-0 items-center gap-2">
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mx-1 data-[orientation=vertical]:h-4 data-[orientation=vertical]:self-center" />
<div className="hidden h-8 min-w-[160px] items-center gap-2 rounded-md px-2 text-sm text-muted-foreground lg:flex">
<Search className="size-4" />
<span>{currentUser.display_name}</span>
</div>
{runningTimer ? (
<button
type="button"
className="flex h-8 min-w-0 items-center gap-1.5 rounded-md border bg-card px-2 text-xs shadow-xs"
onClick={() => {
setSelectedTimerId(runningTimer.id);
navigate("/timer");
}}
>
<span className="size-1.5 rounded-full bg-emerald-500" aria-hidden="true" />
<span className="hidden max-w-28 truncate font-medium sm:inline">{runningTimer.ticketNumber}</span>
<span className="font-mono font-semibold tracking-normal">{formatTimer(runningElapsedSeconds)}</span>
</button>
) : null}
<div className="flex items-center gap-2 md:hidden">
<LayoutGrid className="size-4" />
<span className="truncate text-sm font-semibold">TicketTracker</span>
</div>
</div>
<QuickTimerStarter
inputId="quick-ticket-number-desktop"
className="mx-3 hidden max-w-md flex-1 md:flex"
onStartTimer={startQuickTimer}
/>
<div className="flex items-center gap-2">
<ThemeSwitcher />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="rounded-full border bg-background">
<UserCircle className="size-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuLabel>
<span className="block truncate">{currentUser.display_name}</span>
<span className="block truncate text-xs font-normal text-muted-foreground">{currentUser.username}</span>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => navigate("/profile")}>
<UserCog className="size-4" />
Profil
</DropdownMenuItem>
<DropdownMenuItem onClick={() => void handleLogout()}>
<LogOut className="size-4" />
Abmelden
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</header>
<div className="border-b bg-background/95 px-3 py-2 md:hidden">
<QuickTimerStarter inputId="quick-ticket-number-mobile" onStartTimer={startQuickTimer} />
</div>
<main className="min-h-0 min-w-0 flex-1 overflow-x-hidden p-3 sm:p-4 md:p-5">
<div className="mx-auto w-full max-w-screen-2xl">
{route.page === "timer" ? (
<TimerPage
timers={timers}
setTimers={setTimers}
selectedTimerId={selectedTimerId}
setSelectedTimerId={setSelectedTimerId}
tick={tick}
/>
) : null}
{route.page === "analysis" ? <AnalysisPage onNavigate={navigate} /> : null}
{route.page === "monthly-close" ? <MonthlyClosePage onNavigate={navigate} /> : null}
{route.page === "statistics" ? <StatisticsPage onNavigate={navigate} /> : null}
{route.page === "recurring" ? <RecurringBillingsPage /> : null}
{route.page === "profile" ? <ProfilePage currentUser={currentUser} onUserUpdated={setCurrentUser} /> : null}
{route.page === "admin-users" && currentUser.role === "admin" ? <AdminUsersPage currentUser={currentUser} /> : null}
{route.page === "admin-users" && currentUser.role !== "admin" ? (
<TimerPage
timers={timers}
setTimers={setTimers}
selectedTimerId={selectedTimerId}
setSelectedTimerId={setSelectedTimerId}
tick={tick}
/>
) : null}
{route.page === "ticket" ? (
<TicketDetailPage periodType={route.periodType} period={route.period} ticketId={route.ticketId} onNavigate={navigate} />
) : null}
</div>
</main>
</SidebarInset>
</SidebarProvider>
);
}