From dcc115a82c6c572b96af105eac758841befb32b7 Mon Sep 17 00:00:00 2001 From: mboehmlaender Date: Tue, 11 Aug 2026 07:57:58 +0200 Subject: [PATCH] Add global ticket search --- backend/src/index.ts | 56 ++++++++++++++++++ frontend/src/App.tsx | 130 +++++++++++++++++++++++++++++++++++++++--- frontend/src/api.ts | 5 ++ frontend/src/types.ts | 13 +++++ 4 files changed, 197 insertions(+), 7 deletions(-) diff --git a/backend/src/index.ts b/backend/src/index.ts index 83f6a66..3fde079 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -2681,6 +2681,62 @@ app.get("/api/tickets/lookup", requireUser, async (req, res) => { }); }); +app.get("/api/tickets/search", requireUser, async (req, res) => { + const user = currentUser(req); + const rawQuery = typeof req.query.q === "string" ? req.query.q.trim() : ""; + + if (rawQuery.length < 2) { + res.json({ tickets: [] }); + return; + } + + const normalizedTicketQuery = rawQuery.replace(/^ticket#/iu, "").trim(); + const searchPattern = `%${rawQuery}%`; + const ticketNumberPattern = `%${normalizedTicketQuery || rawQuery}%`; + + const result = await query( + ` + SELECT + t.id AS ticket_id, + t.ticket_number, + t.organization_id, + COALESCE(o.name, t.customer_name, '') AS organization_name, + COALESCE(o.name, t.customer_name, '') AS customer_name, + t.work_type, + MAX(s.started_at) AS latest_started_at, + to_char(MAX(s.started_at) AT TIME ZONE 'Europe/Berlin', 'YYYY-MM') AS latest_period, + COUNT(s.id)::int AS session_count, + COALESCE(SUM(s.rounded_minutes), 0)::int AS total_minutes + FROM tickets t + JOIN sessions s ON s.ticket_id = t.id + LEFT JOIN organizations o ON o.id = t.organization_id + WHERE s.user_id = $1 + AND ( + t.ticket_number ILIKE $2 + OR replace(t.ticket_number, 'Ticket#', '') ILIKE $3 + OR COALESCE(o.name, '') ILIKE $2 + OR COALESCE(t.customer_name, '') ILIKE $2 + OR COALESCE(s.customer_name, '') ILIKE $2 + OR s.activity ILIKE $2 + ) + GROUP BY t.id, t.ticket_number, t.organization_id, o.name, t.customer_name, t.work_type + ORDER BY + CASE + WHEN lower(t.ticket_number) = lower($4) THEN 0 + WHEN lower(replace(t.ticket_number, 'Ticket#', '')) = lower($5) THEN 1 + WHEN t.ticket_number ILIKE $3 THEN 2 + ELSE 3 + END, + MAX(s.started_at) DESC, + t.ticket_number ASC + LIMIT 12; + `, + [user.id, searchPattern, ticketNumberPattern, rawQuery, normalizedTicketQuery] + ); + + res.json({ tickets: result.rows }); +}); + app.patch("/api/tickets/:ticketId", requireUser, async (req, res) => { const ticketId = parsePositiveInteger(req.params.ticketId, "ticketId"); const ticketNumber = parseTrackableTicketNumber(req.body.ticketNumber); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c07d7e6..e63ffb5 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -56,8 +56,8 @@ import { import { usePreferencesStore } from "@/stores/preferences/preferences-provider"; import { cn } from "./lib/utils"; -import { ApiError, getCurrentUser, getSetupStatus, logout, lookupTicket } from "./api"; -import { formatTimer } from "./format"; +import { ApiError, getCurrentUser, getSetupStatus, logout, lookupTicket, searchTickets } from "./api"; +import { formatDateTime, formatMinutes, formatTimer } from "./format"; import { activeElapsedMs, pauseEntry, readStoredTimers, resumeEntry, storageKeyForUser, ticketPattern, type TimerEntry } from "./timers"; import { AdminUsersPage } from "./views/AdminUsersPage"; import { AnalysisPage } from "./views/AnalysisPage"; @@ -70,7 +70,7 @@ import { StatisticsPage } from "./views/StatisticsPage"; import { SetupPage } from "./views/SetupPage"; import { TicketDetailPage } from "./views/TicketDetailPage"; import { TimerPage } from "./views/TimerPage"; -import type { AuthUser, PeriodType } from "./types"; +import type { AuthUser, PeriodType, TicketSearchResult } from "./types"; import type { Dispatch, DragEvent, SetStateAction } from "react"; type NavMenuEntry = @@ -271,6 +271,118 @@ function QuickTimerStarter({ className, inputId, onStartTimer }: QuickTimerStart ); } +type HeaderTicketSearchProps = { + className?: string; + onNavigate: (to: string) => void; +}; + +function HeaderTicketSearch({ className, onNavigate }: HeaderTicketSearchProps) { + const [query, setQuery] = useState(""); + const [results, setResults] = useState([]); + const [open, setOpen] = useState(false); + const [loading, setLoading] = useState(false); + const trimmedQuery = query.trim(); + + useEffect(() => { + if (trimmedQuery.length < 2) { + setResults([]); + setLoading(false); + return; + } + + let cancelled = false; + setLoading(true); + + const timeout = window.setTimeout(async () => { + try { + const result = await searchTickets(trimmedQuery); + + if (!cancelled) { + setResults(result.tickets); + } + } catch { + if (!cancelled) { + setResults([]); + } + } finally { + if (!cancelled) { + setLoading(false); + } + } + }, 220); + + return () => { + cancelled = true; + window.clearTimeout(timeout); + }; + }, [trimmedQuery]); + + function openTicket(ticket: TicketSearchResult) { + onNavigate(`/analysis/month/${ticket.latest_period}/tickets/${ticket.ticket_id}`); + setQuery(""); + setResults([]); + setOpen(false); + } + + function submit(event: FormEvent) { + event.preventDefault(); + + if (results[0]) { + openTicket(results[0]); + } + } + + return ( +
+ + + setOpen(true)} + onBlur={() => window.setTimeout(() => setOpen(false), 120)} + onChange={(event) => { + setQuery(event.currentTarget.value); + setOpen(true); + }} + /> + {open && trimmedQuery.length >= 2 ? ( +
+
+ {loading ?

Sucht...

: null} + {!loading && results.length === 0 ?

Keine Tickets gefunden.

: null} + {!loading + ? results.map((ticket) => ( + + )) + : null} +
+
+ ) : null} + + ); +} + type SidebarTimersProps = { timers: TimerEntry[]; selectedTimerId: string | null; @@ -916,10 +1028,14 @@ export function App() {
-
- - {currentUser.display_name} -
+ {!isAdmin ? ( + + ) : ( +
+ + {currentUser.display_name} +
+ )} {runningTimer ? (