import { BarChart3, CheckCircle2, ClipboardCheck, Command, ChartNoAxesCombined, CircleHelp, FileText, FolderKanban, GripVertical, LayoutGrid, ListTodo, LogOut, Moon, Pause, Play, Plus, Repeat, Search, Shield, Sun, Timer, UserCog, UserCircle, X, } from "lucide-react"; import { FormEvent, MouseEvent, useCallback, 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 { HelpLink } from "@/components/HelpLink"; 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, getSetupStatus, logout, lookupTicket, searchTickets } from "./api"; import { formatDateTime, formatMinutes, 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 { FaqPage } from "./views/FaqPage"; import { LoginPage } from "./views/LoginPage"; import { MonthlyClosePage } from "./views/MonthlyClosePage"; import { ProfilePage } from "./views/ProfilePage"; import { ProjectsPage } from "./views/ProjectsPage"; import { RecurringBillingsPage } from "./views/RecurringBillingsPage"; import { StatisticsPage } from "./views/StatisticsPage"; import { SetupPage } from "./views/SetupPage"; import { TicketDetailPage } from "./views/TicketDetailPage"; import { TimerPage } from "./views/TimerPage"; import { TodosPage } from "./views/TodosPage"; import type { AuthUser, PeriodType, TicketDetailPeriodType, TicketSearchResult } from "./types"; import type { Dispatch, DragEvent, SetStateAction } from "react"; type NavMenuEntry = | { type: "item"; href: string; label: string; icon: typeof Timer; active: boolean; } | { type: "separator"; key: string; }; type OpenTicketNavItem = { key: string; ticketId: string; ticketNumber: string; periodType: TicketDetailPeriodType; period: string; path: string; dirty: boolean; }; function openTicketsStorageKey(userId: string) { return `tickettracker.openTickets.${userId}`; } function pathWithoutSearch(value: string) { return value.split("?")[0]; } function readOpenTickets(userId: string): OpenTicketNavItem[] { try { const raw = localStorage.getItem(openTicketsStorageKey(userId)); if (!raw) { return []; } const parsed = JSON.parse(raw) as Partial[]; return parsed .filter((item) => item.key && item.ticketId && item.ticketNumber && item.path) .map((item) => ({ key: String(item.key), ticketId: String(item.ticketId), ticketNumber: String(item.ticketNumber), periodType: item.periodType === "day" ? "day" : item.periodType === "month" ? "month" : "all", period: item.period ? String(item.period) : "all", path: String(item.path), dirty: false })); } catch { return []; } } function routeFromPath(pathWithSearch: string) { const [pathname, search = ""] = pathWithSearch.split("?"); const searchParams = new URLSearchParams(search); const ticketMatch = pathname.match(/^\/tickets\/(\d+)$/); if (ticketMatch) { return { page: "ticket" as const, periodType: "all" as const, period: searchParams.get("month") ?? "all", ticketId: ticketMatch[1], }; } const periodTicketMatch = pathname.match(/^\/analysis\/(month|day)\/([^/]+)\/tickets\/(\d+)$/); if (periodTicketMatch) { const sourcePeriodType = periodTicketMatch[1] as PeriodType; const sourcePeriod = periodTicketMatch[2]; return { page: "ticket" as const, periodType: "all" as const, period: sourcePeriodType === "day" ? sourcePeriod.slice(0, 7) : sourcePeriod, ticketId: periodTicketMatch[3], }; } const legacyTicketMatch = pathname.match(/^\/analysis\/(\d{4}-\d{2})\/tickets\/(\d+)$/); if (legacyTicketMatch) { return { page: "ticket" as const, periodType: "all" 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("/projects")) { return { page: "projects" as const }; } if (pathname.startsWith("/todos")) { return { page: "todos" as const }; } if (pathname.startsWith("/faq")) { return { page: "faq" 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}${window.location.search}`); update(); window.addEventListener("popstate", update); return () => window.removeEventListener("popstate", update); }, []); function navigate(to: string) { window.history.pushState({}, "", to); setPath(`${window.location.pathname}${window.location.search}`); } 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 HeaderTicketSearchProps = { className?: string; onNavigate: (to: string) => void; }; function HeaderTicketSearch({ className, onNavigate }: HeaderTicketSearchProps) { const [query, setQuery] = useState(""); const [results, setResults] = useState([]); const [open, setOpen] = useState(false); const [loading, setLoading] = useState(false); const trimmedQuery = query.trim(); useEffect(() => { if (trimmedQuery.length < 2) { setResults([]); setLoading(false); return; } let cancelled = false; setLoading(true); const timeout = window.setTimeout(async () => { try { const result = await searchTickets(trimmedQuery); if (!cancelled) { setResults(result.tickets); } } catch { if (!cancelled) { setResults([]); } } finally { if (!cancelled) { setLoading(false); } } }, 220); return () => { cancelled = true; window.clearTimeout(timeout); }; }, [trimmedQuery]); function openTicket(ticket: TicketSearchResult) { onNavigate(`/tickets/${ticket.ticket_id}`); setQuery(""); setResults([]); setOpen(false); } function submit(event: FormEvent) { event.preventDefault(); if (results[0]) { openTicket(results[0]); } } return (
setOpen(true)} onBlur={() => window.setTimeout(() => setOpen(false), 120)} onChange={(event) => { setQuery(event.currentTarget.value); setOpen(true); }} /> {open && trimmedQuery.length >= 2 ? (
{loading ?

Sucht...

: null} {!loading && results.length === 0 ?

Keine Tickets gefunden.

: null} {!loading ? results.map((ticket) => ( )) : null}
) : null} ); } 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 [setupRequired, setSetupRequired] = useState(false); 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()); const [openTickets, setOpenTickets] = useState([]); const [draggedOpenTicketKey, setDraggedOpenTicketKey] = useState(null); useEffect(() => { async function loadUser() { try { const setup = await getSetupStatus(); if (setup.required) { setSetupRequired(true); setCurrentUser(null); return; } setSetupRequired(false); 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); setOpenTickets([]); return; } if (currentUser.role === "admin") { setTimers([]); setSelectedTimerId(null); setTimerOwnerId(currentUser.id); setTimersLoaded(true); setOpenTickets([]); return; } setTimers(readStoredTimers(currentUser.id)); setOpenTickets(readOpenTickets(currentUser.id)); setTimerOwnerId(currentUser.id); setTimersLoaded(true); }, [currentUser]); useEffect(() => { if (!currentUser || currentUser.role === "admin") { return; } localStorage.setItem( openTicketsStorageKey(currentUser.id), JSON.stringify(openTickets.map((ticket) => ({ ...ticket, dirty: false }))), ); }, [currentUser, openTickets]); useEffect(() => { if (!currentUser || currentUser.role === "admin" || !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 || currentUser.role === "admin" || !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, isProject: Boolean(result.ticket?.is_project), projectName: result.ticket?.project_name ?? null }; } catch { return { id: timer.id, organizationName: null, workType: null, isProject: false, projectName: 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, isProject: result.isProject, projectName: result.projectName, ticketLookupDone: true }; }) ); } void hydrateTimers(); return () => { cancelled = true; }; }, [currentUser, timers, timersLoaded, timerOwnerId]); useEffect(() => { const interval = window.setInterval(() => setTick(Date.now()), 500); return () => window.clearInterval(interval); }, []); useEffect(() => { if (!currentUser || currentUser.role !== "admin") { return; } if (route.page === "admin-users" || route.page === "profile" || route.page === "faq") { return; } navigate("/admin/users"); }, [currentUser, route.page]); async function startQuickTimer(rawTicketNumber: string) { if (currentUser?.role === "admin") { toast.error("Admin erfasst keine Sessions", { description: "Der Adminbereich ist nur für die Systemverwaltung vorgesehen." }); return false; } 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, isProject: Boolean(ticketResult.ticket?.is_project), projectName: ticketResult.ticket?.project_name ?? 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: ticketResult.ticket?.is_project ? `${ticketNumber} ist ein Projekt` : ticketNumber }); return true; } async function handleLogout() { await logout().catch(() => undefined); setCurrentUser(null); navigate("/timer"); } const updateOpenTicket = useCallback((ticket: OpenTicketNavItem) => { setOpenTickets((current) => { const existingIndex = current.findIndex((item) => item.key === ticket.key); if (existingIndex >= 0) { return current.map((item, index) => (index === existingIndex ? ticket : item)); } return [...current, ticket].slice(-12); }); }, []); function moveOpenTicket(draggedKey: string, targetKey: string) { if (draggedKey === targetKey) { return; } setOpenTickets((current) => { const draggedIndex = current.findIndex((item) => item.key === draggedKey); const targetIndex = current.findIndex((item) => item.key === targetKey); if (draggedIndex < 0 || targetIndex < 0) { return current; } const next = [...current]; const [dragged] = next.splice(draggedIndex, 1); next.splice(targetIndex, 0, dragged); return next; }); } function startOpenTicketDrag(event: DragEvent, ticket: OpenTicketNavItem) { setDraggedOpenTicketKey(ticket.key); event.dataTransfer.effectAllowed = "move"; event.dataTransfer.setData("text/plain", ticket.key); } function overOpenTicket(event: DragEvent, ticket: OpenTicketNavItem) { const draggedKey = draggedOpenTicketKey || event.dataTransfer.getData("text/plain"); if (!draggedKey || draggedKey === ticket.key) { return; } event.preventDefault(); event.dataTransfer.dropEffect = "move"; } function dropOpenTicket(event: DragEvent, ticket: OpenTicketNavItem) { event.preventDefault(); const draggedKey = draggedOpenTicketKey || event.dataTransfer.getData("text/plain"); if (draggedKey) { moveOpenTicket(draggedKey, ticket.key); } setDraggedOpenTicketKey(null); } function closeOpenTicket(ticket: OpenTicketNavItem) { const current = openTickets.find((item) => item.key === ticket.key) ?? ticket; if (current.dirty) { toast.warning("Ticket hat ungespeicherte Änderungen", { description: "Bitte speichere zuerst die offenen Werte im Ticket, bevor du es aus der Navigation schließt." }); return; } setOpenTickets((items) => items.filter((item) => item.key !== ticket.key)); if (pathWithoutSearch(path) === pathWithoutSearch(ticket.path)) { navigate("/analysis"); } } if (authLoading) { return (
TicketTracker wird geladen...
); } if (setupRequired) { return ( { setSetupRequired(false); setCurrentUser(user); navigate("/admin/users"); }} onRestoreComplete={() => { setSetupRequired(false); setCurrentUser(null); navigate("/"); }} /> ); } if (!currentUser) { return ; } const navItems: NavMenuEntry[] = [ ...(currentUser.role === "admin" ? [ { type: "item" as const, href: "/admin/users", label: "Administration", icon: Shield, active: path.startsWith("/admin/users") }, { type: "separator" as const, key: "before-profile" }, { type: "item" as const, href: "/profile", label: "Profil", icon: UserCog, active: path.startsWith("/profile") }, { type: "separator" as const, key: "before-faq" }, { type: "item" as const, href: "/faq", label: "FAQ", icon: CircleHelp, active: path.startsWith("/faq") } ] : [ { type: "item" as const, href: "/timer", label: "Session starten", icon: Timer, active: path.startsWith("/timer") || path === "/" }, { type: "item" as const, href: "/analysis", label: "Auswertung", icon: BarChart3, active: path.startsWith("/analysis") }, { type: "separator" as const, key: "after-analysis" }, { type: "item" as const, href: "/monthly-close", label: "Monatsabschluss", icon: ClipboardCheck, active: path.startsWith("/monthly-close") }, { type: "separator" as const, key: "after-monthly-close" }, { type: "item" as const, href: "/statistics", label: "Statistiken", icon: ChartNoAxesCombined, active: path.startsWith("/statistics") }, { type: "item" as const, href: "/projects", label: "Projekte", icon: FolderKanban, active: path.startsWith("/projects") }, { type: "item" as const, href: "/todos", label: "ToDos", icon: ListTodo, active: path.startsWith("/todos") }, { type: "item" as const, href: "/recurring", label: "Fixe Abrechnung", icon: Repeat, active: path.startsWith("/recurring") }, { type: "separator" as const, key: "before-profile" }, { type: "item" as const, href: "/profile", label: "Profil", icon: UserCog, active: path.startsWith("/profile") }, { type: "separator" as const, key: "before-faq" }, { type: "item" as const, href: "/faq", label: "FAQ", icon: CircleHelp, active: path.startsWith("/faq") } ]) ]; const isAdmin = currentUser.role === "admin"; const runningTimer = timers.find((timer) => timer.phase === "running") ?? null; const runningElapsedSeconds = runningTimer ? Math.floor(activeElapsedMs(runningTimer, tick) / 1000) : 0; return ( TicketTracker {!isAdmin ? ( <> Session starten navigate("/timer")} /> ) : null} {isAdmin ? "Verwaltung" : "Workflow"} {navItems.map((item) => item.type === "separator" ? ( ) : ( {item.label} ) )} {!isAdmin && openTickets.length > 0 ? ( Geöffnete Tickets {openTickets.map((ticket) => { const isActive = pathWithoutSearch(path) === pathWithoutSearch(ticket.path); return (
startOpenTicketDrag(event, ticket)} onDragOver={(event) => overOpenTicket(event, ticket)} onDrop={(event) => dropOpenTicket(event, ticket)} onDragEnd={() => setDraggedOpenTicketKey(null)} className={cn( "mx-2 flex cursor-grab items-center gap-1 rounded-md px-2 py-1.5 active:cursor-grabbing", isActive ? "bg-primary text-primary-foreground" : "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground", draggedOpenTicketKey === ticket.key && "opacity-60", )} > {ticket.ticketNumber}
); })}
) : null}
{!isAdmin ?
Abschluss

Offene Bewertungen findest du im Monatsabschluss.

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