From b4281de41fa8b0f1973e4629c2344578b90b8b52 Mon Sep 17 00:00:00 2001 From: "Zimin A.N." Date: Wed, 6 May 2026 20:21:05 +0300 Subject: [PATCH] =?UTF-8?q?feat(dict):=20records=20filter=20=E2=80=94=20se?= =?UTF-8?q?arch=20+=20scope=20chips=20client-side?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit На странице словаря над таблицей появилась 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 ключи. --- ordinis-admin-ui/src/i18n.ts | 10 ++ .../src/routes/dictionaries.$name.tsx | 134 +++++++++++++++--- 2 files changed, 123 insertions(+), 21 deletions(-) diff --git a/ordinis-admin-ui/src/i18n.ts b/ordinis-admin-ui/src/i18n.ts index e3cec77..f79cef5 100644 --- a/ordinis-admin-ui/src/i18n.ts +++ b/ordinis-admin-ui/src/i18n.ts @@ -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)', diff --git a/ordinis-admin-ui/src/routes/dictionaries.$name.tsx b/ordinis-admin-ui/src/routes/dictionaries.$name.tsx index d93ad97..3e1a31c 100644 --- a/ordinis-admin-ui/src/routes/dictionaries.$name.tsx +++ b/ordinis-admin-ui/src/routes/dictionaries.$name.tsx @@ -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(undefined) const [aoiOpen, setAoiOpen] = useState(false) + const [search, setSearch] = useState('') + // Empty Set = "все scope активны". Click toggle добавляет/убирает + // конкретный scope. Это проще объяснить чем "все selected" vs "none". + const [scopeFilter, setScopeFilter] = useState>(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 && ( - - - - - {t('dict.col.businessKey')} - name / code - {t('dict.col.scope')} - {t('dict.col.locale')} - {t('dict.col.validFrom')} - {t('dict.col.actions')} - - - - {recordsResult.data.map((r) => ( + <> +
+
+ setSearch(e.target.value)} + placeholder={t('dict.filter.searchPlaceholder')} + aria-label={t('dict.filter.searchPlaceholder')} + /> +
+
+ {SCOPE_ORDER.map((s) => { + const active = scopeFilter.has(s) + return ( + + ) + })} +
+ {filtersActive && ( +
+ + {t('dict.filter.matched', { count: filteredCount, total: totalRecords })} + + +
+ )} +
+ + {filteredRecords.length === 0 ? ( + + ) : ( + +
+ + + {t('dict.col.businessKey')} + name / code + {t('dict.col.scope')} + {t('dict.col.locale')} + {t('dict.col.validFrom')} + {t('dict.col.actions')} + + + + {filteredRecords.map((r) => ( {r.businessKey} @@ -247,10 +337,12 @@ function DictionaryDetail() { - ))} - -
-
+ ))} + + + + )} + )}