142 lines
4.5 KiB
TypeScript
142 lines
4.5 KiB
TypeScript
import { useState } from 'react'
|
||
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
|
||
import { useTranslation } from 'react-i18next'
|
||
import {
|
||
Badge,
|
||
EmptyState,
|
||
LoadingBlock,
|
||
PageHeader,
|
||
Panel,
|
||
QueryErrorState,
|
||
SearchInput,
|
||
} from '@/ui'
|
||
import { useSearch } from '@/api/queries'
|
||
|
||
type SearchSearch = { q?: string }
|
||
|
||
const validateSearch = (raw: Record<string, unknown>): SearchSearch => {
|
||
if (typeof raw.q === 'string' && raw.q.length > 0) return { q: raw.q }
|
||
return {}
|
||
}
|
||
|
||
export const Route = createFileRoute('/search')({
|
||
component: SearchPage,
|
||
validateSearch,
|
||
})
|
||
|
||
/**
|
||
* Smart JSONB search across all dictionaries (CEO plan v1 stretch).
|
||
*
|
||
* <p>Backend: GET /api/v1/search?q=&size&perDict — ILIKE с trigram-индексом
|
||
* на dictionary_records.data::text. Min query length = 3 (trigram threshold).
|
||
*
|
||
* <p>UI: search input → grouped results per dict с per-row link на full
|
||
* record. URL state ?q=… — share-friendly.
|
||
*/
|
||
function SearchPage() {
|
||
const { t } = useTranslation()
|
||
const urlSearch = Route.useSearch()
|
||
const navigate = useNavigate({ from: Route.fullPath })
|
||
const [input, setInput] = useState(urlSearch.q ?? '')
|
||
|
||
const result = useSearch(urlSearch.q)
|
||
|
||
const handleSubmit = (e: React.FormEvent) => {
|
||
e.preventDefault()
|
||
const trimmed = input.trim()
|
||
void navigate({ search: trimmed ? { q: trimmed } : {}, replace: true })
|
||
}
|
||
|
||
const tooShort = input.length > 0 && input.length < 3
|
||
const hasQuery = Boolean(urlSearch.q && urlSearch.q.length >= 3)
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<PageHeader
|
||
title={t('search.title')}
|
||
description={t('search.description')}
|
||
/>
|
||
|
||
<form onSubmit={handleSubmit} className="flex gap-2">
|
||
<div className="flex-1 max-w-2xl">
|
||
<SearchInput
|
||
value={input}
|
||
onChange={(e) => setInput(e.target.value)}
|
||
placeholder={t('search.placeholder')}
|
||
aria-label={t('search.placeholder')}
|
||
autoFocus
|
||
/>
|
||
</div>
|
||
</form>
|
||
|
||
{tooShort && (
|
||
<p className="text-cell text-mute">{t('search.tooShort')}</p>
|
||
)}
|
||
|
||
{hasQuery && result.isLoading && (
|
||
<LoadingBlock size="md" label={t('loading')} />
|
||
)}
|
||
|
||
{hasQuery && result.error && (
|
||
<QueryErrorState error={result.error} onRetry={() => result.refetch()} />
|
||
)}
|
||
|
||
{hasQuery && result.data && result.data.total === 0 && (
|
||
<EmptyState title={t('search.empty')} />
|
||
)}
|
||
|
||
{hasQuery && result.data && result.data.total > 0 && (
|
||
<>
|
||
<p className="text-cell text-mute">
|
||
{t('search.totalHits', { count: result.data.total })}
|
||
</p>
|
||
<div className="space-y-3">
|
||
{result.data.groups.map((g) => (
|
||
<Panel key={g.dictName}>
|
||
<div className="flex items-center justify-between mb-2">
|
||
<Link
|
||
to="/dictionaries/$name"
|
||
params={{ name: g.dictName }}
|
||
search={{ q: urlSearch.q }}
|
||
className="text-body font-sans text-accent hover:underline"
|
||
>
|
||
{g.dictDisplayName ?? g.dictName}
|
||
</Link>
|
||
<Badge variant="neutral">{g.count}</Badge>
|
||
</div>
|
||
<ul className="divide-y divide-regolith">
|
||
{g.items.map((it) => (
|
||
<li
|
||
key={`${g.dictName}-${it.businessKey}`}
|
||
className="flex items-center justify-between gap-3 py-2 text-cell"
|
||
>
|
||
<Link
|
||
to="/dictionaries/$name"
|
||
params={{ name: g.dictName }}
|
||
search={{ q: it.businessKey }}
|
||
className="font-mono text-accent hover:underline truncate"
|
||
>
|
||
{it.businessKey}
|
||
</Link>
|
||
<div className="flex items-center gap-2 shrink-0">
|
||
<Badge variant="neutral">{it.dataScope}</Badge>
|
||
<span className="text-mute">
|
||
{new Date(it.createdAt).toLocaleString()}
|
||
</span>
|
||
</div>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</Panel>
|
||
))}
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{!hasQuery && !tooShort && (
|
||
<p className="text-cell text-mute">{t('search.intro')}</p>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|