feat(dict): records filter — search + scope chips client-side

На странице словаря над таблицей появилась filter bar:
- SearchInput (поиск по businessKey + JSON.stringify(data) — покрывает
  локализованные name, code, описания)
- 3 scope chips PUBLIC/INTERNAL/RESTRICTED с цветными dot'ами,
  toggle включает/исключает scope. Empty Set = все scope видны
  (default state).
- Когда фильтры активны — показываем 'Найдено X из Y' + кнопка
  Сбросить.
- Если фильтр выдал 0 результатов — EmptyState с пояснением
  что под фильтр ничего не подошло.

Фильтрация client-side через useMemo. Записей в одном словаре
обычно ≤100, JSON.stringify + includes() мгновенно. AOI остаётся
server-side (геометрия требует PostGIS) — фильтры комбинируются.

i18n: RU + EN ключи.
This commit is contained in:
Zimin A.N.
2026-05-06 20:21:05 +03:00
parent 668f1b8b5b
commit b4281de41f
2 changed files with 123 additions and 21 deletions
+10
View File
@@ -235,6 +235,11 @@ i18n
'error.failed': 'Не удалось загрузить данные',
'auth.login': 'Войти',
'auth.logout': 'Выйти',
'dict.filter.searchPlaceholder': 'Поиск по записям…',
'dict.filter.scopeLabel': 'Фильтр по scope',
'dict.filter.matched': 'Найдено {{count}} из {{total}}',
'dict.filter.clear': 'Сбросить',
'dict.filter.noMatches': 'По фильтру записей не найдено',
'aoi.button': 'AOI фильтр',
'aoi.activeBbox': 'AOI: bbox {{value}}',
'aoi.activePolygon': 'AOI: полигон ({{points}} точек)',
@@ -470,6 +475,11 @@ i18n
'error.failed': 'Failed to load data',
'auth.login': 'Sign in',
'auth.logout': 'Sign out',
'dict.filter.searchPlaceholder': 'Search records…',
'dict.filter.scopeLabel': 'Filter by scope',
'dict.filter.matched': 'Matched {{count}} of {{total}}',
'dict.filter.clear': 'Clear',
'dict.filter.noMatches': 'No records match the filter',
'aoi.button': 'AOI filter',
'aoi.activeBbox': 'AOI: bbox {{value}}',
'aoi.activePolygon': 'AOI: polygon ({{points}} points)',
@@ -1,4 +1,4 @@
import { useState } from 'react'
import { useMemo, useState } from 'react'
import { createFileRoute, Link } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import axios from 'axios'
@@ -12,6 +12,7 @@ import {
Modal,
PageHeader,
Panel,
SearchInput,
Table,
TableBody,
TableCell,
@@ -23,13 +24,13 @@ import {
import { PlusIcon, PencilSimpleIcon, XCircleIcon, GearIcon, ClockCounterClockwiseIcon, MapTrifoldIcon } from '@phosphor-icons/react'
import { useDictionaryDetail, useRecordRaw, useRecords, type RecordsFilter } from '@/api/queries'
import { useCreateRecord, useUpdateRecord, useCloseRecord } from '@/api/mutations'
import type { CreateRecordRequest, FlattenedRecord } from '@/api/client'
import type { CreateRecordRequest, DataScope, FlattenedRecord } from '@/api/client'
import { SchemaDrivenForm } from '@/components/form/SchemaDrivenForm'
import { DictionaryEditorDialog } from '@/components/schema/DictionaryEditorDialog'
import { RecordHistoryDrawer } from '@/components/record/RecordHistoryDrawer'
import { AoiPickerDialog, type AoiResult } from '@/components/geo/AoiPickerDialog'
import { nowIsoLocal } from '@/lib/dates'
import { SCOPE_BORDER_TOP, SCOPE_DOT } from '@/lib/scope-style'
import { SCOPE_BORDER_TOP, SCOPE_DOT, SCOPE_ORDER } from '@/lib/scope-style'
export const Route = createFileRoute('/dictionaries/$name')({
component: DictionaryDetail,
@@ -61,6 +62,38 @@ function DictionaryDetail() {
const [schemaEditOpen, setSchemaEditOpen] = useState(false)
const [historyKey, setHistoryKey] = useState<string | undefined>(undefined)
const [aoiOpen, setAoiOpen] = useState(false)
const [search, setSearch] = useState('')
// Empty Set = "все scope активны". Click toggle добавляет/убирает
// конкретный scope. Это проще объяснить чем "все selected" vs "none".
const [scopeFilter, setScopeFilter] = useState<Set<DataScope>>(new Set())
const filteredRecords = useMemo(() => {
const raw = recordsResult.data ?? []
if (raw.length === 0) return raw
const q = search.trim().toLowerCase()
const scopes = scopeFilter
return raw.filter((r) => {
if (scopes.size > 0 && !scopes.has(r.dataScope)) return false
if (q.length === 0) return true
// Search по businessKey + JSON.stringify(data) — простой,
// покрывает локализованные поля, code, описания. Stringify
// O(n) в memory, для 100 записей мгновенно.
if (r.businessKey.toLowerCase().includes(q)) return true
const haystack = JSON.stringify(r.data).toLowerCase()
return haystack.includes(q)
})
}, [recordsResult.data, search, scopeFilter])
const toggleScope = (scope: DataScope) => {
setScopeFilter((prev) => {
const next = new Set(prev)
if (next.has(scope)) next.delete(scope)
else next.add(scope)
return next
})
}
const filtersActive = search.length > 0 || scopeFilter.size > 0
const filteredCount = filteredRecords.length
const editingKey = edit.kind === 'edit' ? edit.record.businessKey : undefined
const rawRecordQuery = useRecordRaw(name, editingKey)
@@ -192,20 +225,77 @@ function DictionaryDetail() {
)}
{recordsResult.data && recordsResult.data.length > 0 && (
<Panel>
<Table>
<TableHead>
<TableRow>
<TableHeaderCell>{t('dict.col.businessKey')}</TableHeaderCell>
<TableHeaderCell>name / code</TableHeaderCell>
<TableHeaderCell>{t('dict.col.scope')}</TableHeaderCell>
<TableHeaderCell>{t('dict.col.locale')}</TableHeaderCell>
<TableHeaderCell>{t('dict.col.validFrom')}</TableHeaderCell>
<TableHeaderCell align="right">{t('dict.col.actions')}</TableHeaderCell>
</TableRow>
</TableHead>
<TableBody>
{recordsResult.data.map((r) => (
<>
<div className="flex flex-wrap items-center gap-3">
<div className="flex-1 min-w-[280px] max-w-md">
<SearchInput
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('dict.filter.searchPlaceholder')}
aria-label={t('dict.filter.searchPlaceholder')}
/>
</div>
<div className="flex items-center gap-1.5" role="group" aria-label={t('dict.filter.scopeLabel')}>
{SCOPE_ORDER.map((s) => {
const active = scopeFilter.has(s)
return (
<button
key={s}
type="button"
onClick={() => toggleScope(s)}
aria-pressed={active}
className={[
'inline-flex items-center gap-1.5 px-2.5 py-1 rounded-sm border text-2xs uppercase tracking-label font-secondary transition-colors',
active
? 'border-ultramarain bg-ultramarain/8 text-ultramarain'
: 'border-regolith bg-white text-carbon/70 hover:border-carbon/40',
].join(' ')}
>
<span
className={`inline-block size-2 rounded-full ${SCOPE_DOT[s]}`}
aria-hidden="true"
/>
{t(`dict.list.section.${s}`)}
</button>
)
})}
</div>
{filtersActive && (
<div className="flex items-center gap-2 text-xs text-carbon/70">
<span>
{t('dict.filter.matched', { count: filteredCount, total: totalRecords })}
</span>
<button
type="button"
onClick={() => {
setSearch('')
setScopeFilter(new Set())
}}
className="text-ultramarain hover:underline"
>
{t('dict.filter.clear')}
</button>
</div>
)}
</div>
{filteredRecords.length === 0 ? (
<EmptyState title={t('dict.filter.noMatches')} />
) : (
<Panel>
<Table>
<TableHead>
<TableRow>
<TableHeaderCell>{t('dict.col.businessKey')}</TableHeaderCell>
<TableHeaderCell>name / code</TableHeaderCell>
<TableHeaderCell>{t('dict.col.scope')}</TableHeaderCell>
<TableHeaderCell>{t('dict.col.locale')}</TableHeaderCell>
<TableHeaderCell>{t('dict.col.validFrom')}</TableHeaderCell>
<TableHeaderCell align="right">{t('dict.col.actions')}</TableHeaderCell>
</TableRow>
</TableHead>
<TableBody>
{filteredRecords.map((r) => (
<TableRow key={r.id}>
<TableCell>
<span className="font-mono text-2xs">{r.businessKey}</span>
@@ -247,10 +337,12 @@ function DictionaryDetail() {
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Panel>
))}
</TableBody>
</Table>
</Panel>
)}
</>
)}
<Modal