Add persistent opened ticket navigation

This commit is contained in:
michael committed 2026-08-06 08:32:49 +02:00
1 parent 05c842e406
commit 7d47a05bff
4 files changed
+171 -3

No files matched your search

+134 -2
View File
@@ -5,6 +5,7 @@ import {
Command,
ChartNoAxesCombined,
CircleHelp,
FileText,
LayoutGrid,
LogOut,
Moon,
@@ -18,8 +19,9 @@ import {
Timer,
UserCog,
UserCircle,
X,
} from "lucide-react";
import { FormEvent, MouseEvent, useEffect, useMemo, useState } from "react";
import { FormEvent, MouseEvent, useCallback, useEffect, useMemo, useState } from "react";
import { useShallow } from "zustand/react/shallow";
import { toast } from "sonner";
@@ -83,6 +85,46 @@ type NavMenuEntry =
key: string;
};
type OpenTicketNavItem = {
key: string;
ticketId: string;
ticketNumber: string;
periodType: PeriodType;
period: string;
path: string;
dirty: boolean;
};
function openTicketsStorageKey(userId: string) {
return `tickettracker.openTickets.${userId}`;
}
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.periodType && item.period && item.path)
.map((item) => ({
key: String(item.key),
ticketId: String(item.ticketId),
ticketNumber: String(item.ticketNumber),
periodType: item.periodType === "day" ? "day" : "month",
period: String(item.period),
path: String(item.path),
dirty: false
}));
} catch {
return [];
}
}
function routeFromPath(pathname: string) {
const periodTicketMatch = pathname.match(/^\/analysis\/(month|day)\/([^/]+)\/tickets\/(\d+)$/);
@@ -352,6 +394,7 @@ export function App() {
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[]>([]);
useEffect(() => {
async function loadUser() {
@@ -386,6 +429,7 @@ export function App() {
setSelectedTimerId(null);
setTimerOwnerId(null);
setTimersLoaded(false);
setOpenTickets([]);
return;
}
@@ -394,14 +438,27 @@ export function App() {
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;
@@ -557,6 +614,30 @@ export function App() {
navigate("/timer");
}
const updateOpenTicket = useCallback((ticket: OpenTicketNavItem) => {
setOpenTickets((current) => {
const filtered = current.filter((item) => item.key !== ticket.key);
return [...filtered, ticket].slice(-12);
});
}, []);
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 (path === 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">
@@ -693,6 +774,51 @@ export function App() {
</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 = path === ticket.path;
return (
<SidebarMenuItem key={ticket.key}>
<div
className={cn(
"mx-2 flex items-center gap-1 rounded-md px-2 py-1.5",
isActive ? "bg-primary text-primary-foreground" : "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
)}
>
<a
href={ticket.path}
className="flex min-w-0 flex-1 items-center gap-2 text-sm"
title={`${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>
@@ -816,7 +942,13 @@ export function App() {
/>
) : null}
{!isAdmin && route.page === "ticket" ? (
<TicketDetailPage periodType={route.periodType} period={route.period} ticketId={route.ticketId} onNavigate={navigate} />
<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>