1191 lines
42 KiB
TypeScript
1191 lines
42 KiB
TypeScript
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<OpenTicketNavItem>[];
|
|
|
|
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<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>
|
|
<HelpLink anchor="timer" label="Hilfe zum schnellen Timerstart" className="hidden md:inline-flex" />
|
|
</form>
|
|
);
|
|
}
|
|
|
|
type HeaderTicketSearchProps = {
|
|
className?: string;
|
|
onNavigate: (to: string) => void;
|
|
};
|
|
|
|
function HeaderTicketSearch({ className, onNavigate }: HeaderTicketSearchProps) {
|
|
const [query, setQuery] = useState("");
|
|
const [results, setResults] = useState<TicketSearchResult[]>([]);
|
|
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 (
|
|
<form className={cn("relative min-w-0", className)} onSubmit={submit}>
|
|
<label className="sr-only" htmlFor="global-ticket-search">
|
|
Tickets suchen
|
|
</label>
|
|
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
|
<Input
|
|
id="global-ticket-search"
|
|
className="h-8 bg-background pl-8"
|
|
placeholder="Ticket, Kunde, Tätigkeit"
|
|
value={query}
|
|
onFocus={() => setOpen(true)}
|
|
onBlur={() => window.setTimeout(() => setOpen(false), 120)}
|
|
onChange={(event) => {
|
|
setQuery(event.currentTarget.value);
|
|
setOpen(true);
|
|
}}
|
|
/>
|
|
{open && trimmedQuery.length >= 2 ? (
|
|
<div className="absolute left-0 top-[calc(100%+0.35rem)] z-50 w-[min(28rem,calc(100vw-2rem))] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-lg">
|
|
<div className="max-h-80 overflow-y-auto py-1">
|
|
{loading ? <p className="px-3 py-2 text-sm text-muted-foreground">Sucht...</p> : null}
|
|
{!loading && results.length === 0 ? <p className="px-3 py-2 text-sm text-muted-foreground">Keine Tickets gefunden.</p> : null}
|
|
{!loading
|
|
? results.map((ticket) => (
|
|
<button
|
|
key={`${ticket.latest_period}:${ticket.ticket_id}`}
|
|
type="button"
|
|
className="flex w-full cursor-pointer flex-col gap-1 px-3 py-2 text-left hover:bg-accent hover:text-accent-foreground"
|
|
onMouseDown={(event) => {
|
|
event.preventDefault();
|
|
openTicket(ticket);
|
|
}}
|
|
>
|
|
<span className="flex min-w-0 items-center justify-between gap-3">
|
|
<span className="flex min-w-0 items-center gap-2">
|
|
<span className="truncate text-sm font-medium">{ticket.ticket_number}</span>
|
|
{ticket.is_project ? <span className="rounded-full border px-1.5 py-0.5 text-[10px] uppercase text-muted-foreground">Projekt</span> : null}
|
|
</span>
|
|
<span className="shrink-0 text-xs text-muted-foreground">{formatMinutes(ticket.total_minutes)}</span>
|
|
</span>
|
|
<span className="truncate text-xs text-muted-foreground">
|
|
{ticket.project_name || ticket.organization_name || ticket.customer_name || "Keine Organisation"} · {ticket.session_count} Session(s)
|
|
{ticket.latest_started_at ? ` · zuletzt ${formatDateTime(ticket.latest_started_at)}` : ""}
|
|
</span>
|
|
</button>
|
|
))
|
|
: null}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</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.isProject ? "Projekt · " : ""}
|
|
{timer.projectName || 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 [setupRequired, setSetupRequired] = useState(false);
|
|
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());
|
|
const [openTickets, setOpenTickets] = useState<OpenTicketNavItem[]>([]);
|
|
const [draggedOpenTicketKey, setDraggedOpenTicketKey] = useState<string | null>(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<HTMLDivElement>, ticket: OpenTicketNavItem) {
|
|
setDraggedOpenTicketKey(ticket.key);
|
|
event.dataTransfer.effectAllowed = "move";
|
|
event.dataTransfer.setData("text/plain", ticket.key);
|
|
}
|
|
|
|
function overOpenTicket(event: DragEvent<HTMLDivElement>, 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<HTMLDivElement>, 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 (
|
|
<main className="grid min-h-screen place-items-center bg-background p-4 text-sm text-muted-foreground">
|
|
TicketTracker wird geladen...
|
|
</main>
|
|
);
|
|
}
|
|
|
|
if (setupRequired) {
|
|
return (
|
|
<SetupPage
|
|
onAdminCreated={(user) => {
|
|
setSetupRequired(false);
|
|
setCurrentUser(user);
|
|
navigate("/admin/users");
|
|
}}
|
|
onRestoreComplete={() => {
|
|
setSetupRequired(false);
|
|
setCurrentUser(null);
|
|
navigate("/");
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (!currentUser) {
|
|
return <LoginPage onLogin={setCurrentUser} />;
|
|
}
|
|
|
|
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 (
|
|
<SidebarProvider
|
|
defaultOpen
|
|
style={
|
|
{
|
|
"--sidebar-width": "16rem",
|
|
} as React.CSSProperties
|
|
}
|
|
>
|
|
<Sidebar variant="sidebar" collapsible="icon">
|
|
<SidebarHeader>
|
|
<SidebarMenu>
|
|
<SidebarMenuItem>
|
|
<SidebarMenuButton asChild>
|
|
<a href={isAdmin ? "/admin/users" : "/timer"} onClick={navHandler(isAdmin ? "/admin/users" : "/timer")}>
|
|
<Command />
|
|
<span className="font-semibold text-base">TicketTracker</span>
|
|
</a>
|
|
</SidebarMenuButton>
|
|
</SidebarMenuItem>
|
|
</SidebarMenu>
|
|
</SidebarHeader>
|
|
|
|
<SidebarContent>
|
|
{!isAdmin ? (
|
|
<>
|
|
<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")}
|
|
/>
|
|
</>
|
|
) : null}
|
|
|
|
<SidebarGroup>
|
|
<SidebarGroupLabel>{isAdmin ? "Verwaltung" : "Workflow"}</SidebarGroupLabel>
|
|
<SidebarGroupContent>
|
|
<SidebarMenu>
|
|
{navItems.map((item) =>
|
|
item.type === "separator" ? (
|
|
<SidebarMenuItem key={item.key} className="py-1 group-data-[collapsible=icon]:hidden">
|
|
<Separator />
|
|
</SidebarMenuItem>
|
|
) : (
|
|
<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>
|
|
|
|
{!isAdmin && openTickets.length > 0 ? (
|
|
<SidebarGroup className="pt-0 group-data-[collapsible=icon]:hidden">
|
|
<SidebarGroupLabel className="h-6 px-2 text-xs">Geöffnete Tickets</SidebarGroupLabel>
|
|
<SidebarGroupContent>
|
|
<SidebarMenu>
|
|
{openTickets.map((ticket) => {
|
|
const isActive = pathWithoutSearch(path) === pathWithoutSearch(ticket.path);
|
|
|
|
return (
|
|
<SidebarMenuItem key={ticket.key}>
|
|
<div
|
|
draggable
|
|
onDragStart={(event) => 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",
|
|
)}
|
|
>
|
|
<GripVertical className="size-3.5 shrink-0 text-muted-foreground" />
|
|
<a
|
|
href={ticket.path}
|
|
className="flex min-w-0 flex-1 items-center gap-2 text-sm"
|
|
title={
|
|
ticket.periodType === "all"
|
|
? `${ticket.ticketNumber} (alle Sessions)`
|
|
: `${ticket.ticketNumber} (${ticket.periodType === "month" ? "Monat" : "Tag"} ${ticket.period})`
|
|
}
|
|
onClick={navHandler(ticket.path)}
|
|
>
|
|
<span className={cn("size-2 shrink-0 rounded-full", ticket.dirty ? "bg-amber-400" : isActive ? "bg-primary-foreground" : "bg-muted-foreground/70")} />
|
|
<FileText className="size-4 shrink-0" />
|
|
<span className="truncate">{ticket.ticketNumber}</span>
|
|
</a>
|
|
<Button
|
|
type="button"
|
|
size="icon"
|
|
variant="ghost"
|
|
className={cn("size-6 shrink-0", isActive ? "hover:bg-primary-foreground/15 hover:text-primary-foreground" : "")}
|
|
onClick={() => closeOpenTicket(ticket)}
|
|
aria-label={`${ticket.ticketNumber} schließen`}
|
|
>
|
|
<X className="size-3.5" />
|
|
</Button>
|
|
</div>
|
|
</SidebarMenuItem>
|
|
);
|
|
})}
|
|
</SidebarMenu>
|
|
</SidebarGroupContent>
|
|
</SidebarGroup>
|
|
) : null}
|
|
|
|
</SidebarContent>
|
|
|
|
<SidebarFooter>
|
|
{!isAdmin ? <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> : null}
|
|
<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" />
|
|
{!isAdmin ? (
|
|
<HeaderTicketSearch className="hidden w-72 lg:block" onNavigate={navigate} />
|
|
) : (
|
|
<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>
|
|
{!isAdmin ? (
|
|
<QuickTimerStarter
|
|
inputId="quick-ticket-number-desktop"
|
|
className="mx-3 hidden max-w-md flex-1 md:flex"
|
|
onStartTimer={startQuickTimer}
|
|
/>
|
|
) : null}
|
|
<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>
|
|
{!isAdmin ? <div className="border-b bg-background/95 px-3 py-2 md:hidden">
|
|
<QuickTimerStarter inputId="quick-ticket-number-mobile" onStartTimer={startQuickTimer} />
|
|
</div> : null}
|
|
<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">
|
|
{!isAdmin && route.page === "timer" ? (
|
|
<TimerPage
|
|
timers={timers}
|
|
setTimers={setTimers}
|
|
selectedTimerId={selectedTimerId}
|
|
setSelectedTimerId={setSelectedTimerId}
|
|
tick={tick}
|
|
/>
|
|
) : null}
|
|
{!isAdmin && route.page === "analysis" ? <AnalysisPage onNavigate={navigate} /> : null}
|
|
{!isAdmin && route.page === "monthly-close" ? <MonthlyClosePage onNavigate={navigate} /> : null}
|
|
{!isAdmin && route.page === "statistics" ? <StatisticsPage onNavigate={navigate} /> : null}
|
|
{!isAdmin && route.page === "projects" ? <ProjectsPage onNavigate={navigate} onStartTimer={startQuickTimer} /> : null}
|
|
{!isAdmin && route.page === "todos" ? <TodosPage onNavigate={navigate} /> : null}
|
|
{!isAdmin && route.page === "recurring" ? <RecurringBillingsPage /> : null}
|
|
{route.page === "faq" ? <FaqPage /> : null}
|
|
{route.page === "profile" ? <ProfilePage currentUser={currentUser} onUserUpdated={setCurrentUser} /> : null}
|
|
{route.page === "admin-users" && isAdmin ? <AdminUsersPage currentUser={currentUser} /> : null}
|
|
{route.page === "admin-users" && !isAdmin ? (
|
|
<TimerPage
|
|
timers={timers}
|
|
setTimers={setTimers}
|
|
selectedTimerId={selectedTimerId}
|
|
setSelectedTimerId={setSelectedTimerId}
|
|
tick={tick}
|
|
/>
|
|
) : null}
|
|
{!isAdmin && route.page === "ticket" ? (
|
|
<TicketDetailPage
|
|
periodType={route.periodType}
|
|
period={route.period}
|
|
ticketId={route.ticketId}
|
|
onNavigate={navigate}
|
|
onOpenTicketChange={updateOpenTicket}
|
|
/>
|
|
) : null}
|
|
{isAdmin && route.page !== "admin-users" && route.page !== "profile" && route.page !== "faq" ? <AdminUsersPage currentUser={currentUser} /> : null}
|
|
</div>
|
|
</main>
|
|
</SidebarInset>
|
|
</SidebarProvider>
|
|
);
|
|
}
|