112 lines
4.2 KiB
JavaScript
112 lines
4.2 KiB
JavaScript
/* Global search — GET /search/fetch. Deep-links use live UUIDs so the target
|
||
screen can resolve the row. Debounced so we don't fire on every keystroke. */
|
||
|
||
import { useEffect, useRef, useState } from 'react'
|
||
import { useNavigate } from 'react-router-dom'
|
||
import { useQuery } from '@tanstack/react-query'
|
||
|
||
import { Avatar, Icon } from '../ui/primitives'
|
||
import { qk } from '../lib/queryKeys'
|
||
import * as searchApi from '../api/search'
|
||
|
||
export default function GlobalSearch({ inputRef }) {
|
||
const navigate = useNavigate()
|
||
const [q, setQ] = useState('')
|
||
const [debounced, setDebounced] = useState('')
|
||
const [open, setOpen] = useState(false)
|
||
const boxRef = useRef(null)
|
||
|
||
useEffect(() => {
|
||
const t = setTimeout(() => setDebounced(q.trim()), 250)
|
||
return () => clearTimeout(t)
|
||
}, [q])
|
||
|
||
const query = useQuery({
|
||
queryKey: qk.search.query({ q: debounced }),
|
||
queryFn: async () => {
|
||
const res = await searchApi.fetch({ q: debounced, limit: 4 })
|
||
return res?.data ?? { jobs: [], candidates: [], managers: [] }
|
||
},
|
||
enabled: debounced.length > 0,
|
||
})
|
||
|
||
const results = debounced ? (query.data ?? { jobs: [], candidates: [], managers: [] }) : null
|
||
const jobs = results?.jobs ?? []
|
||
const candidates = results?.candidates ?? []
|
||
const managers = results?.managers ?? []
|
||
const empty = results && !query.isPending && !jobs.length && !candidates.length && !managers.length
|
||
|
||
function go(path, state) {
|
||
setQ('')
|
||
setOpen(false)
|
||
navigate(path, { state })
|
||
}
|
||
|
||
return (
|
||
<div className="topbar-search" onClick={(e) => e.stopPropagation()}>
|
||
<Icon name="search" />
|
||
<input
|
||
type="text"
|
||
ref={inputRef}
|
||
value={q}
|
||
placeholder="Search jobs, candidates, managers…"
|
||
autoComplete="off"
|
||
onChange={(e) => {
|
||
setQ(e.target.value)
|
||
setOpen(Boolean(e.target.value.trim()))
|
||
}}
|
||
onFocus={() => setOpen(Boolean(q.trim()))}
|
||
/>
|
||
<div className={`search-results${open && results ? ' open' : ''}`} ref={boxRef}>
|
||
{results && (
|
||
<>
|
||
{query.isPending && <div className="search-empty">Searching…</div>}
|
||
{query.isError && <div className="search-empty">Couldn’t search. Try again.</div>}
|
||
{!query.isPending && !query.isError && (
|
||
<>
|
||
{jobs.length > 0 && <div className="search-group-label">Jobs</div>}
|
||
{jobs.map((j) => (
|
||
<div key={j.id} className="search-item" onClick={() => go('/jobs', { openJob: j.id })}>
|
||
<span className="kpi-icn i-indigo" style={{ width: 32, height: 32, borderRadius: 8 }}>
|
||
<Icon name="briefcase" />
|
||
</span>
|
||
<div>
|
||
<div className="si-title">{j.title}</div>
|
||
<div className="si-sub">{[j.department, j.location].filter(Boolean).join(' · ') || 'Job'}</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
|
||
{candidates.length > 0 && <div className="search-group-label">Candidates</div>}
|
||
{candidates.map((c) => (
|
||
<div key={c.id} className="search-item" onClick={() => go('/candidates', { openCandidate: c.id })}>
|
||
<Avatar name={c.name} />
|
||
<div>
|
||
<div className="si-title">{c.name}</div>
|
||
<div className="si-sub">{c.email || 'Candidate'}</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
|
||
{managers.length > 0 && <div className="search-group-label">Hiring Managers</div>}
|
||
{managers.map((m) => (
|
||
<div key={m.id} className="search-item" onClick={() => go('/managers', { openManager: m.id })}>
|
||
<Avatar name={m.name} />
|
||
<div>
|
||
<div className="si-title">{m.name}</div>
|
||
<div className="si-sub">{m.role_name || m.email || 'Hiring manager'}</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
|
||
{empty && <div className="search-empty">No results for “{q}”</div>}
|
||
</>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
<kbd className="search-kbd">⌘K</kbd>
|
||
</div>
|
||
)
|
||
}
|