Files
mdm-ordinis/ordinis-admin-ui/src/routes/search.tsx
T
2026-05-12 12:41:48 +00:00

142 lines
4.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}