feat(admin-ui): group dictionaries by scope with color accent
40 справочников в плоском grid тяжело сканировать. Разделили на секции PUBLIC / INTERNAL / RESTRICTED с цветными accent-полосами слева у карточек (emerald / amber / rose). Search фильтрует across all sections, пустые секции скрыты. Цвет даёт instant scan, секции — explicit разделение.
This commit is contained in:
@@ -68,6 +68,9 @@ i18n
|
||||
'dict.list.recordCount_few': '{{count}} записи',
|
||||
'dict.list.recordCount_many': '{{count}} записей',
|
||||
'dict.list.recordCount_other': '{{count}} записей',
|
||||
'dict.list.section.PUBLIC': 'Публичные',
|
||||
'dict.list.section.INTERNAL': 'Внутренние',
|
||||
'dict.list.section.RESTRICTED': 'Ограниченные',
|
||||
'dict.empty': 'В этом справочнике пока нет записей',
|
||||
'dict.col.businessKey': 'Бизнес-ключ',
|
||||
'dict.col.scope': 'Scope',
|
||||
@@ -239,6 +242,9 @@ i18n
|
||||
'dict.list.found': 'Showing {{shown}} of {{total}}',
|
||||
'dict.list.recordCount_one': '{{count}} record',
|
||||
'dict.list.recordCount_other': '{{count}} records',
|
||||
'dict.list.section.PUBLIC': 'Public',
|
||||
'dict.list.section.INTERNAL': 'Internal',
|
||||
'dict.list.section.RESTRICTED': 'Restricted',
|
||||
'dict.empty': 'No records in this dictionary yet',
|
||||
'dict.col.businessKey': 'Business key',
|
||||
'dict.col.scope': 'Scope',
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
} from '@nstart/ui'
|
||||
import { PlusIcon } from '@phosphor-icons/react'
|
||||
import { useDictionaries } from '@/api/queries'
|
||||
import type { DictionaryDefinition } from '@/api/client'
|
||||
import type { DataScope, DictionaryDefinition } from '@/api/client'
|
||||
import { DictionaryEditorDialog } from '@/components/schema/DictionaryEditorDialog'
|
||||
|
||||
export const Route = createFileRoute('/dictionaries/')({
|
||||
@@ -27,6 +27,20 @@ const matchesQuery = (d: DictionaryDefinition, q: string): boolean => {
|
||||
return haystack.includes(q)
|
||||
}
|
||||
|
||||
const SCOPE_ORDER: ReadonlyArray<DataScope> = ['PUBLIC', 'INTERNAL', 'RESTRICTED']
|
||||
|
||||
const SCOPE_DOT: Record<DataScope, string> = {
|
||||
PUBLIC: 'bg-emerald-500',
|
||||
INTERNAL: 'bg-amber-500',
|
||||
RESTRICTED: 'bg-rose-500',
|
||||
}
|
||||
|
||||
const SCOPE_ACCENT: Record<DataScope, string> = {
|
||||
PUBLIC: 'before:bg-emerald-500',
|
||||
INTERNAL: 'before:bg-amber-500',
|
||||
RESTRICTED: 'before:bg-rose-500',
|
||||
}
|
||||
|
||||
function DictionariesPage() {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
@@ -41,6 +55,16 @@ function DictionariesPage() {
|
||||
return data.filter((d) => matchesQuery(d, q))
|
||||
}, [data, deferredQuery])
|
||||
|
||||
const groupedByScope = useMemo(() => {
|
||||
const map: Record<DataScope, DictionaryDefinition[]> = {
|
||||
PUBLIC: [],
|
||||
INTERNAL: [],
|
||||
RESTRICTED: [],
|
||||
}
|
||||
for (const d of filtered) map[d.scope].push(d)
|
||||
return map
|
||||
}, [filtered])
|
||||
|
||||
const createButton = (
|
||||
<Button
|
||||
type="button"
|
||||
@@ -111,39 +135,63 @@ function DictionariesPage() {
|
||||
{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}
|
||||
<div className="space-y-8">
|
||||
{SCOPE_ORDER.map((scope) => {
|
||||
const items = groupedByScope[scope]
|
||||
if (items.length === 0) return null
|
||||
return (
|
||||
<section key={scope} className="space-y-3">
|
||||
<h2 className="flex items-center gap-2 text-2xs uppercase tracking-label text-carbon/70">
|
||||
<span
|
||||
className={`inline-block size-2 rounded-full ${SCOPE_DOT[scope]}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="font-primary text-sm normal-case tracking-normal text-ultramarain">
|
||||
{t(`dict.list.section.${scope}`)}
|
||||
</span>
|
||||
<span className="text-carbon/50">· {items.length}</span>
|
||||
</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 className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{items.map((d) => (
|
||||
<Link
|
||||
key={d.id}
|
||||
to="/dictionaries/$name"
|
||||
params={{ name: d.name }}
|
||||
className={`relative block bg-white border border-regolith rounded-lg p-4 pl-5 transition shadow-card hover:shadow-hover hover:border-ultramarain/40 before:absolute before:left-0 before:top-0 before:bottom-0 before:w-1 before:rounded-l-lg ${SCOPE_ACCENT[d.scope]}`}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2 gap-2">
|
||||
<h3 className="font-primary text-base text-ultramarain">
|
||||
{d.displayName ?? d.name}
|
||||
</h3>
|
||||
<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>
|
||||
{typeof d.recordCount === 'number' && (
|
||||
<Badge variant="neutral">
|
||||
{t('dict.list.recordCount', { count: d.recordCount })}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</section>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user