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:
1 parent
aad247dd4a
commit
a0a2392e3d
4 files changed
+160
No files matched your search
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user