/** * Dictionary Catalog — список со связями (View D из handoff). * * Bundle-grouped cards с scope-color strip, search/scope/bundle filters * и "только со связями" toggle. См. design_handoff_dictionary_catalog/ * (`design/proto/view-list.jsx` + `screenshots/D-list-view.png`). * * URL state preserved для shareable links: `?q=&scope=PUBLIC,INTERNAL&bundle=cuod&deps=1`. * * FK chips: outgoing fk[] парсится из schemaJson properties (x-references), но для * каталога fetch'ить полный detail per dict expensive (N+1). На каталоге показываем * лишь refBy via `useDictionaryDependents` per card (TanStack Query кеширует * параллельные запросы). Outgoing fk будет добавлен через batch backend endpoint * (followup), сейчас рендерится только если backend начнёт возвращать в DictionaryDefinition. */ import { useDeferredValue, useMemo, useState } from 'react' import { createFileRoute, useNavigate } from '@tanstack/react-router' import { useTranslation } from 'react-i18next' import { Alert, Badge, Button, EmptyState, LoadingBlock, PageHeader } from '@/ui' import { PlusIcon } from '@phosphor-icons/react' import { useQueries } from '@tanstack/react-query' import { dictionaryDependentsQuery, useDictionaries, useDictionaryDependents, useRecords } from '@/api/queries' import type { DataScope, DictionaryDefinition, SchemaDependent } from '@/api/client' import { DictionaryEditorDialog } from '@/components/schema/DictionaryEditorDialog' import { useCanMutate } from '@/auth/useCanMutate' import { SCOPE_BG_TINT, SCOPE_DOT, SCOPE_ORDER, SCOPE_TEXT } from '@/lib/scope-style' // ===== Search params ===== const SCOPE_VALUES = new Set(['PUBLIC', 'INTERNAL', 'RESTRICTED']) export type CatalogSearch = { q?: string scope?: string // CSV "PUBLIC,INTERNAL" bundle?: string deps?: '1' } const validateSearch = (raw: Record): CatalogSearch => { const out: CatalogSearch = {} if (typeof raw.q === 'string' && raw.q.length > 0) out.q = raw.q if (typeof raw.scope === 'string' && raw.scope.length > 0) out.scope = raw.scope if (typeof raw.bundle === 'string' && raw.bundle.length > 0) out.bundle = raw.bundle if (raw.deps === '1' || raw.deps === 1) out.deps = '1' return out } // ===== Pure helpers (exported для unit tests) ===== export const parseScopeFilter = (csv: string | undefined): Set => { if (!csv) return new Set() const out = new Set() for (const raw of csv.split(',')) { const candidate = raw.trim().toUpperCase() if (SCOPE_VALUES.has(candidate as DataScope)) out.add(candidate as DataScope) } return out } export const matchesQuery = (d: DictionaryDefinition, q: string): boolean => { if (!q) return true const haystack = [d.name, d.displayName ?? '', d.description ?? ''] .join(' ') .toLowerCase() return haystack.includes(q) } export const groupByBundle = ( list: DictionaryDefinition[], ): Map => { const out = new Map() for (const d of list) { const key = d.bundle || 'default' if (!out.has(key)) out.set(key, []) out.get(key)!.push(d) } // Sort dicts within each bundle by displayName/name for (const [, items] of out) { items.sort((a, b) => (a.displayName ?? a.name).localeCompare(b.displayName ?? b.name), ) } return out } // ===== Route ===== export const Route = createFileRoute('/dictionaries/')({ validateSearch, component: DictionariesPage, }) function DictionariesPage() { const { t } = useTranslation() const navigate = useNavigate({ from: '/dictionaries/' }) const search = Route.useSearch() const { data, isLoading, error } = useDictionaries() const [createOpen, setCreateOpen] = useState(false) const canMutate = useCanMutate() const q = (search.q ?? '').trim().toLowerCase() const deferredQuery = useDeferredValue(q) const scopeFilter = useMemo(() => parseScopeFilter(search.scope), [search.scope]) const bundleFilter = search.bundle const withDepsOnly = search.deps === '1' const setSearch = (next: Partial) => { navigate({ search: (prev) => { const merged: CatalogSearch = { ...prev, ...next } if (!merged.q) delete merged.q if (!merged.scope) delete merged.scope if (!merged.bundle) delete merged.bundle if (!merged.deps) delete merged.deps return merged }, }) } const toggleScope = (scope: DataScope) => { const next = new Set(scopeFilter) if (next.has(scope)) next.delete(scope) else next.add(scope) setSearch({ scope: next.size === 0 ? undefined : Array.from(next).join(',') }) } const setBundle = (bundle: string | undefined) => setSearch({ bundle }) // Батчевая загрузка refBy для всех словарей — для активации withDeps filter. // useQueries dedup'ит c per-card useDictionaryDependents (тот же queryKey), // так что N=37 запросов выполнятся один раз и cached. Запускается ТОЛЬКО // когда deps filter активен — для anonymous browsing N+1 burst не нужен. const dependentsResults = useQueries({ queries: withDepsOnly && data ? data.map((d) => ({ ...dictionaryDependentsQuery(d.name) })) : [], }) const dependentsMap = useMemo(() => { const m = new Map() if (!withDepsOnly || !data) return m data.forEach((d, i) => { const r = dependentsResults[i] // Пока loading или error — считаем 0 (не показываем). После загрузки — // refBy.length. Уникализация не нужна для бинарного hasDeps теста. m.set(d.name, r?.data?.length ?? 0) }) return m }, [data, dependentsResults, withDepsOnly]) const filtered = useMemo(() => { if (!data) return [] return data.filter((d) => { if (scopeFilter.size > 0 && !scopeFilter.has(d.scope)) return false if (bundleFilter && d.bundle !== bundleFilter) return false if (!matchesQuery(d, deferredQuery)) return false // «Со связями» — справочник используется в других справочниках (refBy > 0). // Outgoing FK потребовал бы загрузки полной schema per dict — not worth // the bandwidth для catalog filter. refBy достаточно для semantic. if (withDepsOnly && (dependentsMap.get(d.name) ?? 0) === 0) return false return true }) }, [data, deferredQuery, scopeFilter, bundleFilter, withDepsOnly, dependentsMap]) const bundleCounts = useMemo(() => { const out = new Map() if (data) for (const d of data) out.set(d.bundle, (out.get(d.bundle) ?? 0) + 1) return out }, [data]) const filtersActive = Boolean(q) || scopeFilter.size > 0 || bundleFilter || withDepsOnly const resetFilters = () => navigate({ search: {} }) // Guest mode (anonymous) — кнопка «Создать справочник» скрыта. Backend всё // равно вернёт 401 на POST, но UI hide избегает confusing UX. const createButton = canMutate ? ( // Compact h-8 button — matches toolbar control style (Граф, scope pills). ) : null if (isLoading) return if (error) { return ( {String(error)} ) } if (!data || data.length === 0) { return (
setCreateOpen(false)} onSuccess={(name) => { setCreateOpen(false) navigate({ to: '/dictionaries/$name', params: { name } }) }} />
) } return (
{/* Title block убран — "Справочники N шт." переехал в TopBar breadcrumb per user feedback ("эта надпись в топбаре"). Toolbar + table обёрнуты в ОДНУ rounded card с border, чтобы они визуально не были оторваны друг от друга (user: "чего бар таблицы то оторван от нее?"). Toolbar — surface-2 tinted top section, таблица — white body, разделены `border-b`. */}
{/* Toolbar — top section of the panel, tinted bg + bottom border. * Узкая: px-3 py-1.5 (~6px vertical) — per user feedback ("бар поужать"). */}
{/* Catalog search — filter dicts by name/displayName/description. * type="text" чтобы избежать native browser X — рендерим свой * custom clear button справа когда есть value. Single X. */}
setSearch({ q: e.target.value || undefined }) } placeholder={t('dict.list.search.placeholder', { defaultValue: 'Поиск по справочникам…', })} aria-label={t('dict.list.search.placeholder', { defaultValue: 'Поиск по справочникам…', })} className={`h-8 w-full px-2.5 ${search.q ? 'pr-7' : ''} rounded-md border border-line bg-surface text-cell text-ink placeholder:text-mute focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent`} /> {search.q && ( )}
{/* Scope filter: PUBLIC / INTERNAL / RESTRICTED — pill chips с border */}
{SCOPE_ORDER.map((scope) => { const selected = scopeFilter.has(scope) return ( ) })}
{/* Vertical divider между scope и bundle */}
{/* Empty state OR table — body of the panel (toolbar + body = one card). */} {filtered.length === 0 ? (

{t('dict.list.search.empty')}

{filtersActive && ( )}
) : ( // Row-based table per handoff prototype design/compact.html. // Single flat table (bundle is a column, не group header). // Hover/keyboard клик на строку → navigate к editor. )}
{/* === end catalog panel (toolbar + table) === */} setCreateOpen(false)} onSuccess={(name) => { setCreateOpen(false) navigate({ to: '/dictionaries/$name', params: { name } }) }} />
) } // ===== Helpers ===== type TFunc = ReturnType['t'] /** * Дедуплицирует SchemaDependent[] по `sourceDict` (одно название может * указывать через несколько fields — на катаоге показываем как один chip). * Сохраняет displayName из первого вхождения. */ export const uniqueRefBy = (deps: SchemaDependent[]): SchemaDependent[] => { const seen = new Set() const out: SchemaDependent[] = [] for (const dep of deps) { if (!seen.has(dep.sourceDict)) { seen.add(dep.sourceDict) out.push(dep) } } return out } // ===== Row table per handoff prototype design/compact.html ===== /** * Catalog list — compact row table per handoff Screen 1 (line 173+). * Columns: name+subtitle / id / bundle / scope / records / → / ← / updated. * Click row → navigate к editor. */ type SortKey = 'title' | 'id' | 'bundle' | 'scope' | 'updated' type SortDir = 'asc' | 'desc' function DictionaryListTable({ rows }: { rows: DictionaryDefinition[] }) { const { t } = useTranslation() const [sort, setSort] = useState<{ key: SortKey; dir: SortDir }>({ key: 'title', dir: 'asc', }) const sortedRows = useMemo(() => { const arr = [...rows] arr.sort((a, b) => { let av: string | number = '' let bv: string | number = '' switch (sort.key) { case 'title': av = (a.displayName ?? a.name).toLowerCase() bv = (b.displayName ?? b.name).toLowerCase() break case 'id': av = a.name.toLowerCase() bv = b.name.toLowerCase() break case 'bundle': av = a.bundle.toLowerCase() bv = b.bundle.toLowerCase() break case 'scope': { const order = { PUBLIC: 0, INTERNAL: 1, RESTRICTED: 2 } av = order[a.scope] bv = order[b.scope] break } case 'updated': av = a.updatedAt bv = b.updatedAt break } if (av < bv) return sort.dir === 'asc' ? -1 : 1 if (av > bv) return sort.dir === 'asc' ? 1 : -1 return 0 }) return arr }, [rows, sort]) const toggleSort = (key: SortKey) => { setSort((s) => s.key === key ? { key, dir: s.dir === 'asc' ? 'desc' : 'asc' } : { key, dir: 'asc' }, ) } const arrow = (key: SortKey) => sort.key === key ? (sort.dir === 'asc' ? ' ↑' : ' ↓') : '' return ( // No outer rounded/border wrapper — outer panel handles that (toolbar + table // в одной card). Just table with white header per user feedback. toggleSort('title')} active={sort.key === 'title'}> {t('dict.col.title', { defaultValue: 'Название' })}{arrow('title')} toggleSort('id')} active={sort.key === 'id'}> {t('dict.col.id', { defaultValue: 'id' })}{arrow('id')} toggleSort('bundle')} active={sort.key === 'bundle'}> {t('dict.col.bundle', { defaultValue: 'bundle' })}{arrow('bundle')} toggleSort('scope')} active={sort.key === 'scope'}> {t('dict.col.scope', { defaultValue: 'scope' })}{arrow('scope')} toggleSort('updated')} active={sort.key === 'updated'}> {t('dict.col.updated', { defaultValue: 'изменён' })}{arrow('updated')} {/* Chevron col — affordance что row кликабелен (открывает editor) */} {sortedRows.map((d) => ( ))}
{t('dict.col.records', { defaultValue: 'записей' })}
) } /** * SortableHeader — th cell с click-to-sort affordance. Active column текст * ink (вместо mute), hover dimming. */ function SortableHeader({ children, onClick, active, hideBelow, }: { children: React.ReactNode onClick: () => void active: boolean hideBelow?: 'sm' | 'md' | 'lg' | 'xl' }) { const hideClass = hideBelow ? { sm: 'hidden sm:table-cell', md: 'hidden md:table-cell', lg: 'hidden lg:table-cell', xl: 'hidden xl:table-cell' }[hideBelow] : '' return ( {children} ) } function DictRow({ d, t }: { d: DictionaryDefinition; t: TFunc }) { const navigate = useNavigate({ from: '/dictionaries/' }) const { data: refByRaw } = useDictionaryDependents(d.name) const refBy = useMemo(() => uniqueRefBy(refByRaw ?? []), [refByRaw]) const incomingCount = refBy.length // ЗАПИСЕЙ count — backend dictionaries list endpoint не возвращает // recordCount, fetch records list per row. TanStack Query deduplicate'ит // конкурентные запросы. Show `?` пока loading, иначе length. // TODO когда backend добавит recordCount в list response — fallback на него. const recordsQuery = useRecords(d.name, 'PUBLIC,INTERNAL,RESTRICTED') const recordCount = typeof d.recordCount === 'number' ? d.recordCount : recordsQuery.data ? recordsQuery.data.length : undefined // Relative time per redesign prototype: 12мин / 2ч / 3д / 1мес. // Stale absolute date (YYYY-MM-DD) скрывал scale (5 минут vs 3 месяца — // в формате 05/06/2026 это одинаково "далёкая дата"). const updatedLabel = useMemo(() => { const date = new Date(d.updatedAt) if (isNaN(date.getTime())) return '—' const diffSec = Math.max(0, Math.floor((Date.now() - date.getTime()) / 1000)) if (diffSec < 60) return `${diffSec}с` const diffMin = Math.floor(diffSec / 60) if (diffMin < 60) return `${diffMin}мин` const diffH = Math.floor(diffMin / 60) if (diffH < 24) return `${diffH}ч` const diffD = Math.floor(diffH / 24) if (diffD < 30) return `${diffD}д` const diffMo = Math.floor(diffD / 30) if (diffMo < 12) return `${diffMo}мес` const diffY = Math.floor(diffMo / 12) return `${diffY}г` }, [d.updatedAt]) const handleClick = () => { void navigate({ to: '/dictionaries/$name', params: { name: d.name } }) } const handleKey = (e: React.KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() handleClick() } } // Table rows always white per user feedback ("строки таблиц белые"). // Scope visually conveyed только через 3px left-stripe + colored SCOPE chip // — без subtle row bg tint (был еле виден, плохо читался под page-bg). // Hover full-strength surface-2 (не /40 opacity) — clear visual affordance. return ( {/* name + subtitle (description short) + scope-color left stripe */} {/* 3px scope stripe — absolutely positioned на левом краю row. * td имеет relative чтобы before работало в table context. */}
{d.displayName ?? d.name} {d.approvalRequired && ( {t('dict.list.approval')} )}
{d.description && (

{d.description}

)} {/* id mono */} {d.name} {/* bundle */} {d.bundle} {/* scope badge — colored по scope (PUBLIC accent / INTERNAL warn / RESTRICTED pink) */} {d.scope} {/* records count — fetched per-row via useRecords (cached). */} {recordCount !== undefined ? recordCount : '…'} {/* outgoing FK count (proxy via schemaJson требует detail fetch; пока — placeholder) */} — {/* incoming FK count */} {incomingCount > 0 ? incomingCount : '—'} {/* updated — relative time */} {updatedLabel} {/* Chevron — кликабельность row affordance */} › ) }