Add persistent opened ticket navigation

This commit is contained in:
2026-08-06 08:32:49 +02:00
parent 05c842e406
commit 7d47a05bff
4 changed files with 171 additions and 3 deletions
+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>
+2
View File
@@ -213,6 +213,8 @@ export const helpSections: HelpSection[] = [
"Während der Eingabe wird der Teamspace-Wert nicht sofort gespeichert. Sobald eine Änderung vorliegt, erscheint ein Speichern-Button.",
"Ein leeres Teamspace-Feld entfernt den gespeicherten Teamspace-Wert für diesen Ticket-Tag.",
"Der Teamspace-Wert hängt am Ticket, Benutzer und Tag. Deshalb ist er in Monats- und Tagesansicht identisch.",
"Geöffnete Tickets werden in der Seitenleiste unter Geöffnete Tickets abgelegt und bleiben nach Refresh oder Browserneustart erhalten.",
"Der Schließen-Button in der Seitenleiste entfernt ein Ticket nur, wenn im aktuell geöffneten Ticket keine ungespeicherten Werte mehr vorhanden sind.",
"Sessions können bewertet, bearbeitet oder gelöscht werden. Gelöschte letzte Sessions können dazu führen, dass ein leeres Ticket entfernt wird."
]
},
+33 -1
View File
@@ -29,6 +29,15 @@ type TicketDetailPageProps = {
period: string;
ticketId: string;
onNavigate: (to: string) => void;
onOpenTicketChange?: (ticket: {
key: string;
ticketId: string;
ticketNumber: string;
periodType: PeriodType;
period: string;
path: string;
dirty: boolean;
}) => void;
};
type TicketSessionFormState = {
@@ -141,7 +150,7 @@ function isPartialHoursInput(value: string) {
return /^\d*(?:[,.]\d*)?$/.test(value.trim());
}
export function TicketDetailPage({ periodType, period, ticketId, onNavigate }: TicketDetailPageProps) {
export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onOpenTicketChange }: TicketDetailPageProps) {
const [data, setData] = useState<TicketPeriod | null>(null);
const [loadingId, setLoadingId] = useState<string | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
@@ -241,6 +250,29 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate }: T
}, [data?.sessions]);
const periodTotalMinutes = sessionGroups.reduce((sum, group) => sum + group.totalMinutes, 0);
const periodTeamspaceMinutes = Array.from(dayBillingMap.values()).reduce((sum, minutes) => sum + Number(minutes ?? 0), 0);
const ticketDataDirty = Boolean(
ticket &&
(editTicketNumber !== ticket.ticket_number ||
editOrganizationId !== (ticket.organization_id ?? "") ||
editWorkType !== (ticket.work_type ?? "support"))
);
const hasUnsavedChanges = ticketDataDirty || sessionGroups.some((group) => isDayBillingDirty(group.key));
useEffect(() => {
if (!ticket) {
return;
}
onOpenTicketChange?.({
key: `${periodType}:${period}:${ticket.id}`,
ticketId: ticket.id,
ticketNumber: ticket.ticket_number,
periodType,
period,
path: `/analysis/${periodType}/${period}/tickets/${ticket.id}`,
dirty: hasUnsavedChanges
});
}, [ticket, periodType, period, hasUnsavedChanges, onOpenTicketChange]);
async function setBilling(sessionId: string, billingStatus: BillingStatus) {
if (isPeriodClosed && billingStatus !== null) {