Feature: Globale Schnellsuche (Cmd/Ctrl+K oder „/")

Keine Sprung-zu-Suche bei wachsender Projekt-/Lead-Zahl. Neuer search-Router
(ILIKE ueber Projekte, Leads, offene Blocker) + Command-Palette ueber die
Modal-Basis, getriggert per Cmd/Ctrl+K oder „/". Klick springt zum Ziel.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Raffael SheikhandClaude Opus 4.8 committed 2026-06-07 12:34:25 +02:00
1 parent aad247dd4a
commit a0a2392e3d
4 files changed
+160

No files matched your search

+2
View File
@@ -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;
+38
View File
@@ -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 })),
};
}),
});
@@ -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<string, string> = {
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 ? <Outlet /> : <NotLoggedInHint />}
</main>
{blockerOpen && <BlockerDialog onClose={() => setBlockerOpen(false)} />}
<CommandPalette open={searchOpen} onClose={() => setSearchOpen(false)} />
</div>
);
}
@@ -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 (
<Modal open={open} onClose={onClose} title="Suche">
<input
type="text"
value={q}
onChange={(e) => 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"
/>
<div className="mt-3 max-h-80 space-y-3 overflow-y-auto text-sm">
{debounced.length >= 1 && empty && results.isFetched && (
<p className="text-[var(--color-ink-muted)]">Nichts gefunden.</p>
)}
{data && data.projects.length > 0 && (
<Group title="Projekte">
{data.projects.map((p) => (
<ResultItem
key={p.id}
label={p.archived ? `${p.label} (archiviert)` : p.label}
onClick={() => go(`/projekt/${p.id}`)}
/>
))}
</Group>
)}
{data && data.leads.length > 0 && (
<Group title="Leads">
{data.leads.map((l) => (
<ResultItem key={l.id} label={l.label} onClick={() => go(`/lead/${l.id}`)} />
))}
</Group>
)}
{data && data.blockers.length > 0 && (
<Group title="Offene Blocker">
{data.blockers.map((b) => (
<ResultItem key={b.id} label={b.label} onClick={() => go('/dashboard')} />
))}
</Group>
)}
</div>
</Modal>
);
}
function Group({ title, children }: { title: string; children: ReactNode }) {
return (
<div>
<p className="mb-1 text-xs font-medium uppercase tracking-wide text-[var(--color-ink-faint)]">
{title}
</p>
<ul className="space-y-0.5">{children}</ul>
</div>
);
}
function ResultItem({ label, onClick }: { label: string; onClick: () => void }) {
return (
<li>
<button
type="button"
onClick={onClick}
className="w-full truncate rounded-[var(--radius-sm)] px-2 py-1.5 text-left hover:bg-black/5"
>
{label}
</button>
</li>
);
}