171d049fc5
Backend: - DictionaryRecordRepository.countActiveGroupedByDictionary — один GROUP BY по всем справочникам с фильтром по scope, без N+1. - DictionaryResponse.recordCount (nullable) + factory from(d, count). - DictionaryDefinitionController передаёт счётчики в list/get, фильтрованные по currentScopes() пользователя. Frontend: - SearchInput над сеткой карточек, client-side filter по name/displayName/description (case-insensitive, useDeferredValue). - Badge "N записей" (i18n plural ru/en) в углу карточки. - "Показано N из M" рядом с поиском. - EmptyState когда поиск пустой.
162 lines
5.0 KiB
TypeScript
162 lines
5.0 KiB
TypeScript
import { useDeferredValue, useMemo, useState } from 'react'
|
|
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
|
|
import { useTranslation } from 'react-i18next'
|
|
import {
|
|
Alert,
|
|
Badge,
|
|
Button,
|
|
EmptyState,
|
|
LoadingBlock,
|
|
PageHeader,
|
|
SearchInput,
|
|
} from '@nstart/ui'
|
|
import { PlusIcon } from '@phosphor-icons/react'
|
|
import { useDictionaries } from '@/api/queries'
|
|
import type { DictionaryDefinition } from '@/api/client'
|
|
import { DictionaryEditorDialog } from '@/components/schema/DictionaryEditorDialog'
|
|
|
|
export const Route = createFileRoute('/dictionaries/')({
|
|
component: DictionariesPage,
|
|
})
|
|
|
|
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)
|
|
}
|
|
|
|
function DictionariesPage() {
|
|
const { t } = useTranslation()
|
|
const navigate = useNavigate()
|
|
const { data, isLoading, error } = useDictionaries()
|
|
const [createOpen, setCreateOpen] = useState(false)
|
|
const [query, setQuery] = useState('')
|
|
const deferredQuery = useDeferredValue(query)
|
|
|
|
const filtered = useMemo(() => {
|
|
if (!data) return []
|
|
const q = deferredQuery.trim().toLowerCase()
|
|
return data.filter((d) => matchesQuery(d, q))
|
|
}, [data, deferredQuery])
|
|
|
|
const createButton = (
|
|
<Button
|
|
type="button"
|
|
variant="primary"
|
|
leftIcon={<PlusIcon weight="bold" size={16} />}
|
|
onClick={() => setCreateOpen(true)}
|
|
>
|
|
{t('schema.action.create')}
|
|
</Button>
|
|
)
|
|
|
|
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 (
|
|
<div className="space-y-6">
|
|
<PageHeader
|
|
title={t('nav.dictionaries')}
|
|
description={t('dict.list.subtitle')}
|
|
actions={createButton}
|
|
/>
|
|
|
|
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
|
|
<div className="flex-1 max-w-md">
|
|
<SearchInput
|
|
value={query}
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
placeholder={t('dict.list.search.placeholder')}
|
|
aria-label={t('dict.list.search.placeholder')}
|
|
/>
|
|
</div>
|
|
<span className="text-sm text-carbon/70">
|
|
{t('dict.list.found', { shown: filtered.length, total: data.length })}
|
|
</span>
|
|
</div>
|
|
|
|
{filtered.length === 0 ? (
|
|
<EmptyState title={t('dict.list.search.empty')} />
|
|
) : (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
{filtered.map((d) => (
|
|
<Link
|
|
key={d.id}
|
|
to="/dictionaries/$name"
|
|
params={{ name: d.name }}
|
|
className="block bg-white border border-regolith rounded-lg p-4 transition shadow-card hover:shadow-hover hover:border-ultramarain/40"
|
|
>
|
|
<div className="flex items-start justify-between mb-2 gap-2">
|
|
<h2 className="font-primary text-base text-ultramarain">
|
|
{d.displayName ?? d.name}
|
|
</h2>
|
|
<Badge variant="info">{d.scope}</Badge>
|
|
</div>
|
|
{d.description && (
|
|
<p className="text-sm text-carbon line-clamp-2 mb-3">{d.description}</p>
|
|
)}
|
|
<div className="flex items-center justify-between gap-2 text-2xs uppercase tracking-label text-carbon/60">
|
|
<div className="flex gap-2 min-w-0">
|
|
<span className="truncate">v{d.schemaVersion}</span>
|
|
<span>·</span>
|
|
<span className="truncate">{d.bundle}</span>
|
|
<span>·</span>
|
|
<span className="truncate">{d.supportedLocales.join(', ')}</span>
|
|
</div>
|
|
{typeof d.recordCount === 'number' && (
|
|
<Badge variant="neutral">
|
|
{t('dict.list.recordCount', { count: d.recordCount })}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<DictionaryEditorDialog
|
|
open={createOpen}
|
|
mode={{ kind: 'create' }}
|
|
onClose={() => setCreateOpen(false)}
|
|
onSuccess={(name) => {
|
|
setCreateOpen(false)
|
|
navigate({ to: '/dictionaries/$name', params: { name } })
|
|
}}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|