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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user