Files
mdm-ordinis/ordinis-admin-ui/src/routes/dictionaries.index.tsx
T
Zimin A.N. 7f3241686c fix(ui): remove TopBar search (final) + add clear X to catalog input
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.
2026-05-11 22:07:00 +03:00

680 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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>
)
}