Add persistent opened ticket navigation
This commit is contained in:
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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."
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user