From 4390f9a7da058711b86f62f4945882e451a6871f Mon Sep 17 00:00:00 2001 From: mboehmlaender Date: Thu, 6 Aug 2026 08:39:35 +0200 Subject: [PATCH] Allow manual ordering of opened ticket links --- docs/user-guide.md | 2 +- frontend/src/App.tsx | 70 +++++++++++++++++++++++++++++++++--- frontend/src/help-content.ts | 1 + 3 files changed, 68 insertions(+), 5 deletions(-) diff --git a/docs/user-guide.md b/docs/user-guide.md index 694bd75..54ff9f2 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -216,7 +216,7 @@ 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. +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. Die Reihenfolge bleibt beim Anklicken unveraendert und kann nur per Drag and Drop angepasst werden. 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: diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 94abe00..c07d7e6 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -6,6 +6,7 @@ import { ChartNoAxesCombined, CircleHelp, FileText, + GripVertical, LayoutGrid, LogOut, Moon, @@ -70,7 +71,7 @@ import { SetupPage } from "./views/SetupPage"; import { TicketDetailPage } from "./views/TicketDetailPage"; import { TimerPage } from "./views/TimerPage"; import type { AuthUser, PeriodType } from "./types"; -import type { Dispatch, SetStateAction } from "react"; +import type { Dispatch, DragEvent, SetStateAction } from "react"; type NavMenuEntry = | { @@ -395,6 +396,7 @@ export function App() { const [selectedTimerId, setSelectedTimerId] = useState(null); const [tick, setTick] = useState(Date.now()); const [openTickets, setOpenTickets] = useState([]); + const [draggedOpenTicketKey, setDraggedOpenTicketKey] = useState(null); useEffect(() => { async function loadUser() { @@ -616,11 +618,64 @@ export function App() { const updateOpenTicket = useCallback((ticket: OpenTicketNavItem) => { setOpenTickets((current) => { - const filtered = current.filter((item) => item.key !== ticket.key); - return [...filtered, ticket].slice(-12); + const existingIndex = current.findIndex((item) => item.key === ticket.key); + + if (existingIndex >= 0) { + return current.map((item, index) => (index === existingIndex ? ticket : item)); + } + + return [...current, ticket].slice(-12); }); }, []); + function moveOpenTicket(draggedKey: string, targetKey: string) { + if (draggedKey === targetKey) { + return; + } + + setOpenTickets((current) => { + const draggedIndex = current.findIndex((item) => item.key === draggedKey); + const targetIndex = current.findIndex((item) => item.key === targetKey); + + if (draggedIndex < 0 || targetIndex < 0) { + return current; + } + + const next = [...current]; + const [dragged] = next.splice(draggedIndex, 1); + next.splice(targetIndex, 0, dragged); + return next; + }); + } + + function startOpenTicketDrag(event: DragEvent, ticket: OpenTicketNavItem) { + setDraggedOpenTicketKey(ticket.key); + event.dataTransfer.effectAllowed = "move"; + event.dataTransfer.setData("text/plain", ticket.key); + } + + function overOpenTicket(event: DragEvent, ticket: OpenTicketNavItem) { + const draggedKey = draggedOpenTicketKey || event.dataTransfer.getData("text/plain"); + + if (!draggedKey || draggedKey === ticket.key) { + return; + } + + event.preventDefault(); + event.dataTransfer.dropEffect = "move"; + } + + function dropOpenTicket(event: DragEvent, ticket: OpenTicketNavItem) { + event.preventDefault(); + const draggedKey = draggedOpenTicketKey || event.dataTransfer.getData("text/plain"); + + if (draggedKey) { + moveOpenTicket(draggedKey, ticket.key); + } + + setDraggedOpenTicketKey(null); + } + function closeOpenTicket(ticket: OpenTicketNavItem) { const current = openTickets.find((item) => item.key === ticket.key) ?? ticket; @@ -785,11 +840,18 @@ export function App() { return (
startOpenTicketDrag(event, ticket)} + onDragOver={(event) => overOpenTicket(event, ticket)} + onDrop={(event) => dropOpenTicket(event, ticket)} + onDragEnd={() => setDraggedOpenTicketKey(null)} className={cn( - "mx-2 flex items-center gap-1 rounded-md px-2 py-1.5", + "mx-2 flex cursor-grab items-center gap-1 rounded-md px-2 py-1.5 active:cursor-grabbing", isActive ? "bg-primary text-primary-foreground" : "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground", + draggedOpenTicketKey === ticket.key && "opacity-60", )} > +