7f3241686c
User clarification after MR !91: - "Search из TopBar убран — все верно" — TopBar search должен быть удалён. - "TopBar search вернулся. убери его" — MR !91 ошибочно его вернул. - "два крестика баг в топбаре был а ты и в таблице убрал его" — двойной X was в TopBar (native + custom); в catalog был только custom X на старом input. После refactor catalog input получился без X совсем — fix: добавил кастомный clear button когда q непустой. Changes: - TopBar: removed form + SearchInput + handlers (handleSearchInput / Submit / Clear / inputRef / useState). Removed useEffect ⌘K shortcut (без search input nothing to focus). Right cluster: Docs + VersionBadge + NotificationsBell. ml-auto на right cluster чтобы push'ить вправо без search в центре. - Catalog input: добавлен absolute-positioned X (✕) справа input'а когда `search.q` непустой. `pr-7` на input чтобы текст не наезжал. Clear → `setSearch({ q: undefined })`. Single X confirmed visually.
680 lines
27 KiB
TypeScript
680 lines
27 KiB
TypeScript
/**
|
||
* 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<DataScope>(['PUBLIC', 'INTERNAL', 'RESTRICTED'])
|
||
|
||
export type CatalogSearch = {
|
||
q?: string
|
||
scope?: string // CSV "PUBLIC,INTERNAL"
|
||
bundle?: string
|
||
deps?: '1'
|
||
}
|
||
|
||
const validateSearch = (raw: Record<string, unknown>): 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<DataScope> => {
|
||
if (!csv) return new Set()
|
||
const out = new Set<DataScope>()
|
||
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<string, DictionaryDefinition[]> => {
|
||
const out = new Map<string, DictionaryDefinition[]>()
|
||
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<CatalogSearch>) => {
|
||
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<string, number>()
|
||
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<string, number>()
|
||
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).
|
||
<button
|
||
type="button"
|
||
onClick={() => setCreateOpen(true)}
|
||
className="h-8 px-3 rounded-md bg-navy text-on-accent text-cell font-semibold inline-flex items-center gap-1.5 hover:opacity-90 transition-opacity whitespace-nowrap"
|
||
>
|
||
<PlusIcon weight="bold" size={14} />
|
||
{t('schema.action.create')}
|
||
</button>
|
||
) : null
|
||
|
||
if (isLoading) return <LoadingBlock size="md" label={t('loading')} />
|
||
if (error) {
|
||
return (
|
||
<Alert variant="error" title={t('error.failed')}>
|
||
{String(error)}
|
||
</Alert>
|
||
)
|
||
}
|
||
if (!data || data.length === 0) {
|
||
return (
|
||
<div className="space-y-6">
|
||
<PageHeader
|
||
title={t('nav.dictionaries')}
|
||
description={t('dict.list.subtitle')}
|
||
actions={createButton}
|
||
/>
|
||
<EmptyState title={t('dict.empty')} />
|
||
<DictionaryEditorDialog
|
||
open={createOpen}
|
||
mode={{ kind: 'create' }}
|
||
onClose={() => setCreateOpen(false)}
|
||
onSuccess={(name) => {
|
||
setCreateOpen(false)
|
||
navigate({ to: '/dictionaries/$name', params: { name } })
|
||
}}
|
||
/>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<section
|
||
aria-label={t('dict.list.heading')}
|
||
className="space-y-3"
|
||
>
|
||
{/* Title block убран — "Справочники N шт." переехал в TopBar breadcrumb
|
||
per user feedback ("эта надпись в топбаре").
|
||
Toolbar + table обёрнуты в ОДНУ rounded card с border, чтобы они
|
||
визуально не были оторваны друг от друга (user: "чего бар таблицы
|
||
то оторван от нее?"). Toolbar — surface-2 tinted top section,
|
||
таблица — white body, разделены `border-b`. */}
|
||
<div className="rounded-lg border border-line overflow-hidden bg-surface">
|
||
|
||
{/* Toolbar — top section of the panel, tinted bg + bottom border.
|
||
* Узкая: px-3 py-1.5 (~6px vertical) — per user feedback ("бар поужать"). */}
|
||
<div className="flex flex-wrap items-center gap-2 bg-surface-2 border-b border-line px-3 py-1.5">
|
||
{/* Catalog search — filter dicts by name/displayName/description.
|
||
* type="text" чтобы избежать native browser X — рендерим свой
|
||
* custom clear button справа когда есть value. Single X. */}
|
||
<div className="flex-1 min-w-[200px] max-w-xs relative">
|
||
<input
|
||
type="text"
|
||
value={search.q ?? ''}
|
||
onChange={(e) =>
|
||
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 && (
|
||
<button
|
||
type="button"
|
||
onClick={() => setSearch({ q: undefined })}
|
||
aria-label={t('common.clear', { defaultValue: 'Очистить' })}
|
||
className="absolute right-1.5 top-1/2 -translate-y-1/2 text-mute hover:text-ink rounded-sm p-0.5 hover:bg-surface-2"
|
||
>
|
||
✕
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* Scope filter: PUBLIC / INTERNAL / RESTRICTED — pill chips с border */}
|
||
<div
|
||
role="group"
|
||
aria-label={t('dict.list.filter.scope')}
|
||
className="flex items-center gap-1.5"
|
||
>
|
||
{SCOPE_ORDER.map((scope) => {
|
||
const selected = scopeFilter.has(scope)
|
||
return (
|
||
<button
|
||
key={scope}
|
||
type="button"
|
||
onClick={() => toggleScope(scope)}
|
||
aria-pressed={selected}
|
||
title={t(`dict.list.section.${scope}`)}
|
||
// Scope chips всегда покрашены в свой scope-color (filled bg
|
||
// + colored text) per user feedback ("плашки то покрась").
|
||
// Selected state — ring-1 ring-{scope} + slightly opaque bg.
|
||
className={`text-cap tracking-[0.14em] uppercase font-semibold px-2.5 py-1 rounded-md border transition-all focus:outline-none focus:ring-2 focus:ring-accent/40 ${SCOPE_BG_TINT[scope]} ${SCOPE_TEXT[scope]} ${
|
||
selected
|
||
? `border-current shadow-sm`
|
||
: 'border-transparent opacity-70 hover:opacity-100'
|
||
}`}
|
||
>
|
||
{scope}
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
|
||
{/* Vertical divider между scope и bundle */}
|
||
<span className="h-6 w-px bg-line shrink-0" aria-hidden="true" />
|
||
|
||
{/* Bundle filter — все / cuod / geo / i18n / core */}
|
||
<div
|
||
role="group"
|
||
aria-label="Bundle"
|
||
className="flex items-center gap-1"
|
||
>
|
||
<button
|
||
type="button"
|
||
onClick={() => setBundle(undefined)}
|
||
aria-pressed={!bundleFilter}
|
||
className={`text-cell px-2.5 py-1 rounded-md border transition-colors focus:outline-none focus:ring-2 focus:ring-accent/40 ${
|
||
!bundleFilter
|
||
? 'border-ink bg-surface text-ink ring-1 ring-ink font-medium'
|
||
: 'border-line bg-surface text-ink-2 hover:border-ink-2 hover:bg-surface-2'
|
||
}`}
|
||
>
|
||
{t('dict.list.bundle.all', { defaultValue: 'все' })}
|
||
</button>
|
||
{Array.from(bundleCounts.entries())
|
||
.sort(([a], [b]) => a.localeCompare(b))
|
||
.map(([bundle]) => {
|
||
const selected = bundleFilter === bundle
|
||
return (
|
||
<button
|
||
key={bundle}
|
||
type="button"
|
||
onClick={() => setBundle(selected ? undefined : bundle)}
|
||
aria-pressed={selected}
|
||
className={`text-mono text-cell px-2.5 py-1 rounded-md border transition-colors focus:outline-none focus:ring-2 focus:ring-accent/40 ${
|
||
selected
|
||
? 'border-ink bg-surface text-ink ring-1 ring-ink font-medium'
|
||
: 'border-line bg-surface text-ink-2 hover:border-ink-2 hover:bg-surface-2'
|
||
}`}
|
||
>
|
||
{bundle}
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
|
||
{/* Spacer */}
|
||
<div className="ml-auto flex items-center gap-2">
|
||
{/* N / M counter */}
|
||
<span className="text-cell text-mute whitespace-nowrap tabular-nums">
|
||
{filtered.length} / {data.length}
|
||
</span>
|
||
|
||
{/* Граф ⇄ — link to relations graph view */}
|
||
<button
|
||
type="button"
|
||
onClick={() => navigate({ to: '/graph' })}
|
||
className="h-8 px-2.5 rounded-md border border-line bg-surface text-ink-2 hover:border-ink-2 hover:bg-surface-2 text-cell inline-flex items-center gap-1.5 transition-colors"
|
||
title={t('dict.list.graph', { defaultValue: 'Граф связей' })}
|
||
>
|
||
{t('dict.list.graph', { defaultValue: 'Граф' })}
|
||
<span aria-hidden className="text-mute">⇄</span>
|
||
</button>
|
||
|
||
{/* + Создать (moved here from PageHeader actions per redesign) */}
|
||
{createButton}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Empty state OR table — body of the panel (toolbar + body = one card). */}
|
||
{filtered.length === 0 ? (
|
||
<div className="p-12 text-center bg-surface">
|
||
<p className="font-sans text-title-md text-accent mb-2">
|
||
{t('dict.list.search.empty')}
|
||
</p>
|
||
{filtersActive && (
|
||
<Button type="button" variant="ghost" onClick={resetFilters}>
|
||
{t('dict.list.search.reset')}
|
||
</Button>
|
||
)}
|
||
</div>
|
||
) : (
|
||
// Row-based table per handoff prototype design/compact.html.
|
||
// Single flat table (bundle is a column, не group header).
|
||
// Hover/keyboard клик на строку → navigate к editor.
|
||
<DictionaryListTable rows={filtered} />
|
||
)}
|
||
|
||
</div>
|
||
{/* === end catalog panel (toolbar + table) === */}
|
||
|
||
<DictionaryEditorDialog
|
||
open={createOpen}
|
||
mode={{ kind: 'create' }}
|
||
onClose={() => setCreateOpen(false)}
|
||
onSuccess={(name) => {
|
||
setCreateOpen(false)
|
||
navigate({ to: '/dictionaries/$name', params: { name } })
|
||
}}
|
||
/>
|
||
</section>
|
||
)
|
||
}
|
||
|
||
// ===== Helpers =====
|
||
|
||
type TFunc = ReturnType<typeof useTranslation>['t']
|
||
|
||
/**
|
||
* Дедуплицирует SchemaDependent[] по `sourceDict` (одно название может
|
||
* указывать через несколько fields — на катаоге показываем как один chip).
|
||
* Сохраняет displayName из первого вхождения.
|
||
*/
|
||
export const uniqueRefBy = (deps: SchemaDependent[]): SchemaDependent[] => {
|
||
const seen = new Set<string>()
|
||
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.
|
||
<table className="w-full bg-surface">
|
||
<thead className="bg-surface border-b border-line">
|
||
<tr>
|
||
<SortableHeader onClick={() => toggleSort('title')} active={sort.key === 'title'}>
|
||
{t('dict.col.title', { defaultValue: 'Название' })}{arrow('title')}
|
||
</SortableHeader>
|
||
<SortableHeader hideBelow="md" onClick={() => toggleSort('id')} active={sort.key === 'id'}>
|
||
{t('dict.col.id', { defaultValue: 'id' })}{arrow('id')}
|
||
</SortableHeader>
|
||
<SortableHeader hideBelow="lg" onClick={() => toggleSort('bundle')} active={sort.key === 'bundle'}>
|
||
{t('dict.col.bundle', { defaultValue: 'bundle' })}{arrow('bundle')}
|
||
</SortableHeader>
|
||
<SortableHeader hideBelow="sm" onClick={() => toggleSort('scope')} active={sort.key === 'scope'}>
|
||
{t('dict.col.scope', { defaultValue: 'scope' })}{arrow('scope')}
|
||
</SortableHeader>
|
||
<th scope="col" className="text-cap text-mute text-right px-3 py-2 hidden md:table-cell">
|
||
{t('dict.col.records', { defaultValue: 'записей' })}
|
||
</th>
|
||
<th
|
||
scope="col"
|
||
className="text-cap text-mute text-right px-3 py-2 hidden lg:table-cell"
|
||
title={t('dict.col.outgoingFk', { defaultValue: 'Ссылается (outgoing FK)' })}
|
||
>
|
||
→
|
||
</th>
|
||
<th
|
||
scope="col"
|
||
className="text-cap text-mute text-right px-3 py-2 hidden lg:table-cell"
|
||
title={t('dict.col.incomingFk', { defaultValue: 'Используют (incoming FK)' })}
|
||
>
|
||
←
|
||
</th>
|
||
<SortableHeader hideBelow="xl" onClick={() => toggleSort('updated')} active={sort.key === 'updated'}>
|
||
{t('dict.col.updated', { defaultValue: 'изменён' })}{arrow('updated')}
|
||
</SortableHeader>
|
||
{/* Chevron col — affordance что row кликабелен (открывает editor) */}
|
||
<th scope="col" aria-hidden className="w-8" />
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{sortedRows.map((d) => (
|
||
<DictRow key={d.id} d={d} t={t} />
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
)
|
||
}
|
||
|
||
/**
|
||
* 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 (
|
||
<th
|
||
scope="col"
|
||
className={`text-cap text-left px-3 py-2 cursor-pointer select-none transition-colors ${active ? 'text-ink' : 'text-mute hover:text-ink'} ${hideClass}`}
|
||
onClick={onClick}
|
||
>
|
||
{children}
|
||
</th>
|
||
)
|
||
}
|
||
|
||
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 (
|
||
<tr
|
||
role="link"
|
||
tabIndex={0}
|
||
onClick={handleClick}
|
||
onKeyDown={handleKey}
|
||
className="relative border-b border-line-2 last:border-b-0 cursor-pointer bg-surface hover:bg-surface-2 focus-visible:outline-none focus-visible:bg-accent-bg/30 transition-colors"
|
||
>
|
||
{/* name + subtitle (description short) + scope-color left stripe */}
|
||
<td className="relative px-3 py-2 align-top min-w-0">
|
||
{/* 3px scope stripe — absolutely positioned на левом краю row.
|
||
* td имеет relative чтобы before работало в table context. */}
|
||
<span
|
||
aria-hidden
|
||
className={`absolute left-0 top-0 bottom-0 w-[3px] ${SCOPE_DOT[d.scope]}`}
|
||
/>
|
||
<div className="flex items-center gap-2 pl-1">
|
||
<span className="text-body font-medium text-ink truncate">
|
||
{d.displayName ?? d.name}
|
||
</span>
|
||
{d.approvalRequired && (
|
||
<Badge variant="warning">{t('dict.list.approval')}</Badge>
|
||
)}
|
||
</div>
|
||
{d.description && (
|
||
<p className="text-cell text-mute mt-0.5 truncate max-w-md pl-1">
|
||
{d.description}
|
||
</p>
|
||
)}
|
||
</td>
|
||
{/* id mono */}
|
||
<td className="px-3 py-2 hidden md:table-cell">
|
||
<span className="text-mono text-ink-2">{d.name}</span>
|
||
</td>
|
||
{/* bundle */}
|
||
<td className="px-3 py-2 hidden lg:table-cell">
|
||
<span className="text-cap text-mute">{d.bundle}</span>
|
||
</td>
|
||
{/* scope badge — colored по scope (PUBLIC accent / INTERNAL warn / RESTRICTED pink) */}
|
||
<td className="px-3 py-2 hidden sm:table-cell">
|
||
<span
|
||
className={`inline-flex items-center px-2 py-0.5 rounded-sm text-cap tracking-wider font-semibold uppercase ${SCOPE_BG_TINT[d.scope]} ${SCOPE_TEXT[d.scope]}`}
|
||
>
|
||
{d.scope}
|
||
</span>
|
||
</td>
|
||
{/* records count — fetched per-row via useRecords (cached). */}
|
||
<td className="px-3 py-2 text-right tabular-nums text-mono text-ink-2 hidden md:table-cell">
|
||
{recordCount !== undefined ? recordCount : '…'}
|
||
</td>
|
||
{/* outgoing FK count (proxy via schemaJson требует detail fetch; пока — placeholder) */}
|
||
<td className="px-3 py-2 text-right text-mono text-mute hidden lg:table-cell">—</td>
|
||
{/* incoming FK count */}
|
||
<td className="px-3 py-2 text-right text-mono text-mute hidden lg:table-cell">
|
||
{incomingCount > 0 ? incomingCount : '—'}
|
||
</td>
|
||
{/* updated — relative time */}
|
||
<td className="px-3 py-2 text-mono text-mute tabular-nums hidden xl:table-cell whitespace-nowrap">
|
||
{updatedLabel}
|
||
</td>
|
||
{/* Chevron — кликабельность row affordance */}
|
||
<td aria-hidden className="px-2 py-2 text-mute text-mono text-right">
|
||
›
|
||
</td>
|
||
</tr>
|
||
)
|
||
}
|