diff --git a/apps/api/src/routers/_app.ts b/apps/api/src/routers/_app.ts index cfe567b..5b61582 100644 --- a/apps/api/src/routers/_app.ts +++ b/apps/api/src/routers/_app.ts @@ -14,6 +14,7 @@ import { campaignRouter } from './campaign.js'; import { areaRouter } from './area.js'; import { blockerRouter } from './blocker.js'; import { timeRouter } from './time.js'; +import { searchRouter } from './search.js'; export const appRouter = router({ health: publicProcedure.query(() => ({ @@ -34,6 +35,7 @@ export const appRouter = router({ dayEntry: dayEntryRouter, time: timeRouter, dashboard: dashboardRouter, + search: searchRouter, }); export type AppRouter = typeof appRouter; diff --git a/apps/api/src/routers/search.ts b/apps/api/src/routers/search.ts new file mode 100644 index 0000000..d522d11 --- /dev/null +++ b/apps/api/src/routers/search.ts @@ -0,0 +1,38 @@ +import { z } from 'zod'; +import { and, ilike, isNull } from 'drizzle-orm'; +import { project, lead, blocker } from '@cockpit/db'; +import { router, protectedProcedure } from '../trpc.js'; + +/** + * Globale Schnellsuche über die wichtigsten Entitäten (Projekte, Leads, offene + * Blocker). Postgres ILIKE — für die Teamgröße völlig ausreichend, kein FTS nötig. + */ +export const searchRouter = router({ + all: protectedProcedure + .input(z.object({ q: z.string().min(1).max(100) })) + .query(async ({ ctx, input }) => { + const term = `%${input.q.replace(/[%_]/g, '\\$&')}%`; + const [projects, leads, blockers] = await Promise.all([ + ctx.db + .select({ id: project.id, name: project.name, archiviert: project.archiviert }) + .from(project) + .where(ilike(project.name, term)) + .limit(8), + ctx.db + .select({ id: lead.id, firma: lead.firma }) + .from(lead) + .where(ilike(lead.firma, term)) + .limit(8), + ctx.db + .select({ id: blocker.id, title: blocker.title }) + .from(blocker) + .where(and(ilike(blocker.title, term), isNull(blocker.resolvedAt))) + .limit(8), + ]); + return { + projects: projects.map((p) => ({ id: p.id, label: p.name, archived: p.archiviert })), + leads: leads.map((l) => ({ id: l.id, label: l.firma })), + blockers: blockers.map((b) => ({ id: b.id, label: b.title })), + }; + }), +}); diff --git a/apps/web/src/components/layout/AppShell.tsx b/apps/web/src/components/layout/AppShell.tsx index 9996dfd..d91426c 100644 --- a/apps/web/src/components/layout/AppShell.tsx +++ b/apps/web/src/components/layout/AppShell.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from 'react'; import { TopNav } from './TopNav'; import { useCurrentUser } from '../../hooks/useCurrentUser'; import { BlockerDialog } from '../blocker/BlockerDialog'; +import { CommandPalette } from '../search/CommandPalette'; const SHORTCUT_MAP: Record = { p: '/dashboard', @@ -20,6 +21,7 @@ export function AppShell() { const me = useCurrentUser(); const navigate = useNavigate(); const [blockerOpen, setBlockerOpen] = useState(false); + const [searchOpen, setSearchOpen] = useState(false); // Dev-Mode: bei fehlender Session sofort in den Auto-Login. Auf prod hingegen // zeigen wir die Login-Aufforderung mit dem Authentik-Link. @@ -49,6 +51,12 @@ export function AppShell() { } function onKey(e: KeyboardEvent) { + // Cmd/Ctrl+K öffnet die Suche — auch aus Eingabefeldern heraus. + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { + e.preventDefault(); + setSearchOpen(true); + return; + } const target = e.target as HTMLElement | null; if ( target && @@ -56,6 +64,11 @@ export function AppShell() { ) { return; } + if (e.key === '/') { + e.preventDefault(); + setSearchOpen(true); + return; + } if (e.key === '?' && !waitingForSecond) { // eslint-disable-next-line no-alert alert( @@ -96,6 +109,7 @@ export function AppShell() { {me ? : } {blockerOpen && setBlockerOpen(false)} />} + setSearchOpen(false)} /> ); } diff --git a/apps/web/src/components/search/CommandPalette.tsx b/apps/web/src/components/search/CommandPalette.tsx new file mode 100644 index 0000000..80a097f --- /dev/null +++ b/apps/web/src/components/search/CommandPalette.tsx @@ -0,0 +1,106 @@ +import { useEffect, useState, type ReactNode } from 'react'; +import { useNavigate } from 'react-router'; +import { trpc } from '../../lib/trpc'; +import { Modal } from '../ui/Modal'; + +/** + * Globale Schnellsuche (Cmd/Ctrl+K oder „/"). Tippen sucht debounced über + * Projekte, Leads und offene Blocker; Klick springt zum Ziel. + */ +export function CommandPalette({ open, onClose }: { open: boolean; onClose: () => void }) { + const navigate = useNavigate(); + const [q, setQ] = useState(''); + const [debounced, setDebounced] = useState(''); + + useEffect(() => { + const t = setTimeout(() => setDebounced(q.trim()), 200); + return () => clearTimeout(t); + }, [q]); + + useEffect(() => { + if (!open) { + setQ(''); + setDebounced(''); + } + }, [open]); + + const results = trpc.search.all.useQuery( + { q: debounced }, + { enabled: open && debounced.length >= 1 }, + ); + const data = results.data; + const empty = + !!data && data.projects.length === 0 && data.leads.length === 0 && data.blockers.length === 0; + + function go(path: string) { + onClose(); + navigate(path); + } + + return ( + + setQ(e.target.value)} + placeholder="Projekt, Lead oder Blocker …" + className="mt-2 w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-3 py-2 text-sm focus:border-[var(--color-ink)] focus:outline-none" + /> +
+ {debounced.length >= 1 && empty && results.isFetched && ( +

Nichts gefunden.

+ )} + {data && data.projects.length > 0 && ( + + {data.projects.map((p) => ( + go(`/projekt/${p.id}`)} + /> + ))} + + )} + {data && data.leads.length > 0 && ( + + {data.leads.map((l) => ( + go(`/lead/${l.id}`)} /> + ))} + + )} + {data && data.blockers.length > 0 && ( + + {data.blockers.map((b) => ( + go('/dashboard')} /> + ))} + + )} +
+
+ ); +} + +function Group({ title, children }: { title: string; children: ReactNode }) { + return ( +
+

+ {title} +

+
    {children}
+
+ ); +} + +function ResultItem({ label, onClick }: { label: string; onClick: () => void }) { + return ( +
  • + +
  • + ); +}