Add global ticket search
This commit is contained in:
@@ -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);
|
||||
|
||||
+123
-7
@@ -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<TicketSearchResult[]>([]);
|
||||
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 (
|
||||
<form className={cn("relative min-w-0", className)} onSubmit={submit}>
|
||||
<label className="sr-only" htmlFor="global-ticket-search">
|
||||
Tickets suchen
|
||||
</label>
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
id="global-ticket-search"
|
||||
className="h-8 bg-background pl-8"
|
||||
placeholder="Ticket, Kunde, Tätigkeit"
|
||||
value={query}
|
||||
onFocus={() => setOpen(true)}
|
||||
onBlur={() => window.setTimeout(() => setOpen(false), 120)}
|
||||
onChange={(event) => {
|
||||
setQuery(event.currentTarget.value);
|
||||
setOpen(true);
|
||||
}}
|
||||
/>
|
||||
{open && trimmedQuery.length >= 2 ? (
|
||||
<div className="absolute left-0 top-[calc(100%+0.35rem)] z-50 w-[min(28rem,calc(100vw-2rem))] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-lg">
|
||||
<div className="max-h-80 overflow-y-auto py-1">
|
||||
{loading ? <p className="px-3 py-2 text-sm text-muted-foreground">Sucht...</p> : null}
|
||||
{!loading && results.length === 0 ? <p className="px-3 py-2 text-sm text-muted-foreground">Keine Tickets gefunden.</p> : null}
|
||||
{!loading
|
||||
? results.map((ticket) => (
|
||||
<button
|
||||
key={`${ticket.latest_period}:${ticket.ticket_id}`}
|
||||
type="button"
|
||||
className="flex w-full cursor-pointer flex-col gap-1 px-3 py-2 text-left hover:bg-accent hover:text-accent-foreground"
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
openTicket(ticket);
|
||||
}}
|
||||
>
|
||||
<span className="flex min-w-0 items-center justify-between gap-3">
|
||||
<span className="truncate text-sm font-medium">{ticket.ticket_number}</span>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">{formatMinutes(ticket.total_minutes)}</span>
|
||||
</span>
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{ticket.organization_name || ticket.customer_name || "Keine Organisation"} · {ticket.session_count} Session(s) · zuletzt {formatDateTime(ticket.latest_started_at)}
|
||||
</span>
|
||||
</button>
|
||||
))
|
||||
: null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
type SidebarTimersProps = {
|
||||
timers: TimerEntry[];
|
||||
selectedTimerId: string | null;
|
||||
@@ -916,10 +1028,14 @@ export function App() {
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<Separator orientation="vertical" className="mx-1 data-[orientation=vertical]:h-4 data-[orientation=vertical]:self-center" />
|
||||
<div className="hidden h-8 min-w-[160px] items-center gap-2 rounded-md px-2 text-sm text-muted-foreground lg:flex">
|
||||
<Search className="size-4" />
|
||||
<span>{currentUser.display_name}</span>
|
||||
</div>
|
||||
{!isAdmin ? (
|
||||
<HeaderTicketSearch className="hidden w-72 lg:block" onNavigate={navigate} />
|
||||
) : (
|
||||
<div className="hidden h-8 min-w-[160px] items-center gap-2 rounded-md px-2 text-sm text-muted-foreground lg:flex">
|
||||
<Search className="size-4" />
|
||||
<span>{currentUser.display_name}</span>
|
||||
</div>
|
||||
)}
|
||||
{runningTimer ? (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
StatisticsOverview,
|
||||
TicketMeta,
|
||||
TicketPeriod,
|
||||
TicketSearchResult,
|
||||
WorkType
|
||||
} from "./types";
|
||||
|
||||
@@ -300,6 +301,10 @@ export function lookupTicket(ticketNumber: string) {
|
||||
return request<{ ticket: TicketMeta | null }>(`/api/tickets/lookup?ticketNumber=${encodeURIComponent(ticketNumber)}`);
|
||||
}
|
||||
|
||||
export function searchTickets(query: string) {
|
||||
return request<{ tickets: TicketSearchResult[] }>(`/api/tickets/search?q=${encodeURIComponent(query)}`);
|
||||
}
|
||||
|
||||
export function updateTicket(
|
||||
ticketId: string,
|
||||
payload: {
|
||||
|
||||
@@ -61,6 +61,19 @@ export type TicketMeta = {
|
||||
work_type: WorkType | null;
|
||||
};
|
||||
|
||||
export type TicketSearchResult = {
|
||||
ticket_id: string;
|
||||
ticket_number: string;
|
||||
organization_id: string | null;
|
||||
organization_name: string | null;
|
||||
customer_name: string | null;
|
||||
work_type: WorkType | null;
|
||||
latest_started_at: string;
|
||||
latest_period: string;
|
||||
session_count: number;
|
||||
total_minutes: number;
|
||||
};
|
||||
|
||||
export type TicketSummary = {
|
||||
id: string;
|
||||
ticket_number: string;
|
||||
|
||||
Reference in New Issue
Block a user