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) => { 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 ( ); } type QuickTimerStarterProps = { className?: string; inputId: string; onStartTimer: (ticketNumber: string) => Promise; }; 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 (
setTicketNumber(event.currentTarget.value)} />
); } type SidebarTimersProps = { timers: TimerEntry[]; selectedTimerId: string | null; setSelectedTimerId: Dispatch>; setTimers: Dispatch>; 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 ( Timer
{runningTimer ? "Aktuell aktiv" : timers.length > 0 ? "Bereit" : "Kein Timer"} {runningTimer ?
{sortedTimers.length === 0 ? (

Noch keine Timer gestartet.

) : (
{sortedTimers.map((timer) => { const elapsed = Math.floor(activeElapsedMs(timer, tick) / 1000); const isSelected = selectedTimerId === timer.id; const isRunning = timer.phase === "running"; return (
); })}
)}
); } export function App() { const { path, route, navHandler, navigate } = useAppRouter(); const [currentUser, setCurrentUser] = useState(null); const [authLoading, setAuthLoading] = useState(true); const [timers, setTimers] = useState([]); const [timersLoaded, setTimersLoaded] = useState(false); const [timerOwnerId, setTimerOwnerId] = useState(null); const [selectedTimerId, setSelectedTimerId] = useState(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 (
TicketTracker wird geladen...
); } if (!currentUser) { return ; } 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 ( TicketTracker Session starten navigate("/timer")} /> Workflows {navItems.map((item) => ( {item.label} ))}
Abschluss

Offene Bewertungen findest du im Monatsabschluss.

Worklog lokal
*]: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", )} >
{currentUser.display_name}
{runningTimer ? ( ) : null}
TicketTracker
{currentUser.display_name} {currentUser.username} navigate("/profile")}> Profil void handleLogout()}> Abmelden
{route.page === "timer" ? ( ) : null} {route.page === "analysis" ? : null} {route.page === "monthly-close" ? : null} {route.page === "statistics" ? : null} {route.page === "recurring" ? : null} {route.page === "profile" ? : null} {route.page === "admin-users" && currentUser.role === "admin" ? : null} {route.page === "admin-users" && currentUser.role !== "admin" ? ( ) : null} {route.page === "ticket" ? ( ) : null}
); }