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
+2
View File
@@ -216,6 +216,8 @@ In der Ticketdetailansicht sieht man:
- Teamspace-Wert pro Ticket und Tag - Teamspace-Wert pro Ticket und Tag
- Moeglichkeit, direkt eine Session zu diesem Ticket nachzutragen - Moeglichkeit, direkt eine Session zu diesem Ticket nachzutragen
Geoeffnete Tickets erscheinen in der Seitenleiste unter `Geoeffnete Tickets`. Die Liste bleibt nach Aktualisierung und Browserneustart erhalten. Mit einem Klick auf die Ticketnummer gelangt man direkt zur Ticketdetailansicht zurueck. Der Schliessen-Button entfernt das Ticket aus der Seitenleiste. Wenn im aktuell geoeffneten Ticket noch ungespeicherte Werte vorhanden sind, muss zuerst gespeichert werden.
Die Sessions sind pro Tag gruppiert. In jeder Tagesgruppe stehen: Die Sessions sind pro Tag gruppiert. In jeder Tagesgruppe stehen:
- Tagesdatum - Tagesdatum
+134 -2
View File
@@ -5,6 +5,7 @@ import {
Command, Command,
ChartNoAxesCombined, ChartNoAxesCombined,
CircleHelp, CircleHelp,
FileText,
LayoutGrid, LayoutGrid,
LogOut, LogOut,
Moon, Moon,
@@ -18,8 +19,9 @@ import {
Timer, Timer,
UserCog, UserCog,
UserCircle, UserCircle,
X,
} from "lucide-react"; } 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 { useShallow } from "zustand/react/shallow";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -83,6 +85,46 @@ type NavMenuEntry =
key: string; 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) { function routeFromPath(pathname: string) {
const periodTicketMatch = pathname.match(/^\/analysis\/(month|day)\/([^/]+)\/tickets\/(\d+)$/); const periodTicketMatch = pathname.match(/^\/analysis\/(month|day)\/([^/]+)\/tickets\/(\d+)$/);
@@ -352,6 +394,7 @@ export function App() {
const [timerOwnerId, setTimerOwnerId] = useState<string | null>(null); const [timerOwnerId, setTimerOwnerId] = useState<string | null>(null);
const [selectedTimerId, setSelectedTimerId] = useState<string | null>(null); const [selectedTimerId, setSelectedTimerId] = useState<string | null>(null);
const [tick, setTick] = useState(Date.now()); const [tick, setTick] = useState(Date.now());
const [openTickets, setOpenTickets] = useState<OpenTicketNavItem[]>([]);
useEffect(() => { useEffect(() => {
async function loadUser() { async function loadUser() {
@@ -386,6 +429,7 @@ export function App() {
setSelectedTimerId(null); setSelectedTimerId(null);
setTimerOwnerId(null); setTimerOwnerId(null);
setTimersLoaded(false); setTimersLoaded(false);
setOpenTickets([]);
return; return;
} }
@@ -394,14 +438,27 @@ export function App() {
setSelectedTimerId(null); setSelectedTimerId(null);
setTimerOwnerId(currentUser.id); setTimerOwnerId(currentUser.id);
setTimersLoaded(true); setTimersLoaded(true);
setOpenTickets([]);
return; return;
} }
setTimers(readStoredTimers(currentUser.id)); setTimers(readStoredTimers(currentUser.id));
setOpenTickets(readOpenTickets(currentUser.id));
setTimerOwnerId(currentUser.id); setTimerOwnerId(currentUser.id);
setTimersLoaded(true); setTimersLoaded(true);
}, [currentUser]); }, [currentUser]);
useEffect(() => {
if (!currentUser || currentUser.role === "admin") {
return;
}
localStorage.setItem(
openTicketsStorageKey(currentUser.id),
JSON.stringify(openTickets.map((ticket) => ({ ...ticket, dirty: false }))),
);
}, [currentUser, openTickets]);
useEffect(() => { useEffect(() => {
if (!currentUser || currentUser.role === "admin" || !timersLoaded || timerOwnerId !== currentUser.id) { if (!currentUser || currentUser.role === "admin" || !timersLoaded || timerOwnerId !== currentUser.id) {
return; return;
@@ -557,6 +614,30 @@ export function App() {
navigate("/timer"); 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) { if (authLoading) {
return ( return (
<main className="grid min-h-screen place-items-center bg-background p-4 text-sm text-muted-foreground"> <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> </SidebarGroupContent>
</SidebarGroup> </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> </SidebarContent>
<SidebarFooter> <SidebarFooter>
@@ -816,7 +942,13 @@ export function App() {
/> />
) : null} ) : null}
{!isAdmin && route.page === "ticket" ? ( {!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} ) : null}
{isAdmin && route.page !== "admin-users" && route.page !== "profile" && route.page !== "faq" ? <AdminUsersPage currentUser={currentUser} /> : null} {isAdmin && route.page !== "admin-users" && route.page !== "profile" && route.page !== "faq" ? <AdminUsersPage currentUser={currentUser} /> : null}
</div> </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.", "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.", "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.", "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." "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; period: string;
ticketId: string; ticketId: string;
onNavigate: (to: string) => void; onNavigate: (to: string) => void;
onOpenTicketChange?: (ticket: {
key: string;
ticketId: string;
ticketNumber: string;
periodType: PeriodType;
period: string;
path: string;
dirty: boolean;
}) => void;
}; };
type TicketSessionFormState = { type TicketSessionFormState = {
@@ -141,7 +150,7 @@ function isPartialHoursInput(value: string) {
return /^\d*(?:[,.]\d*)?$/.test(value.trim()); 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 [data, setData] = useState<TicketPeriod | null>(null);
const [loadingId, setLoadingId] = useState<string | null>(null); const [loadingId, setLoadingId] = useState<string | null>(null);
const [deletingId, setDeletingId] = 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]); }, [data?.sessions]);
const periodTotalMinutes = sessionGroups.reduce((sum, group) => sum + group.totalMinutes, 0); 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 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) { async function setBilling(sessionId: string, billingStatus: BillingStatus) {
if (isPeriodClosed && billingStatus !== null) { if (isPeriodClosed && billingStatus !== null) {