From 7d47a05bffda25eb0d90905b1a69feb5e44c07ac Mon Sep 17 00:00:00 2001 From: mboehmlaender Date: Thu, 6 Aug 2026 08:32:49 +0200 Subject: [PATCH] Add persistent opened ticket navigation --- docs/user-guide.md | 2 + frontend/src/App.tsx | 136 +++++++++++++++++++++++- frontend/src/help-content.ts | 2 + frontend/src/views/TicketDetailPage.tsx | 34 +++++- 4 files changed, 171 insertions(+), 3 deletions(-) diff --git a/docs/user-guide.md b/docs/user-guide.md index ee79d6e..694bd75 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -216,6 +216,8 @@ In der Ticketdetailansicht sieht man: - Teamspace-Wert pro Ticket und Tag - 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: - Tagesdatum diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 22279dd..94abe00 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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[]; + + 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(null); const [selectedTimerId, setSelectedTimerId] = useState(null); const [tick, setTick] = useState(Date.now()); + const [openTickets, setOpenTickets] = useState([]); 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 (
@@ -693,6 +774,51 @@ export function App() { + {!isAdmin && openTickets.length > 0 ? ( + + Geöffnete Tickets + + + {openTickets.map((ticket) => { + const isActive = path === ticket.path; + + return ( + +
+ + + + {ticket.ticketNumber} + + +
+
+ ); + })} +
+
+
+ ) : null} + @@ -816,7 +942,13 @@ export function App() { /> ) : null} {!isAdmin && route.page === "ticket" ? ( - + ) : null} {isAdmin && route.page !== "admin-users" && route.page !== "profile" && route.page !== "faq" ? : null} diff --git a/frontend/src/help-content.ts b/frontend/src/help-content.ts index 9ba7d0b..b1d4a72 100644 --- a/frontend/src/help-content.ts +++ b/frontend/src/help-content.ts @@ -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." ] }, diff --git a/frontend/src/views/TicketDetailPage.tsx b/frontend/src/views/TicketDetailPage.tsx index 65fe29d..64afaac 100644 --- a/frontend/src/views/TicketDetailPage.tsx +++ b/frontend/src/views/TicketDetailPage.tsx @@ -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(null); const [loadingId, setLoadingId] = useState(null); const [deletingId, setDeletingId] = useState(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) {