)
}
+
+/**
+ * RecordColumnFilters — auto-derive filterable columns из schema +
+ * records data, render as compact select dropdowns "status ▾", "operator ▾".
+ *
+ * Filterable columns детектятся по двум сигналам:
+ *
+ * - schema property имеет `enum` array → use enum values as options
+ * - иначе walk records, собирай уникальные значения для top non-i18n
+ * string properties (status, operator, type). Если cardinality
+ * (unique count) ≤ 12 — это reasonable filter; иначе skip (нужен search).
+ *
+ *
+ * Replaces прошлый scope filter chips per user feedback ("фильтр в
+ * справочнике по статусу скорее нужен чем по scope").
+ */
+function RecordColumnFilters({
+ schema,
+ records,
+ value,
+ onChange,
+}: {
+ schema?: import('@/api/client').JsonSchema
+ records: FlattenedRecord[]
+ value: Record
+ onChange: (v: Record) => void
+}) {
+ const filters = useMemo(() => {
+ if (!schema?.properties) return []
+ const out: { field: string; label: string; options: string[] }[] = []
+ for (const [field, prop] of Object.entries(schema.properties)) {
+ // Skip localized & FK fields — слишком много unique values.
+ if (prop['x-localized']) continue
+ // Enum-typed → use schema enum values.
+ if (Array.isArray(prop.enum) && prop.enum.length > 0 && prop.enum.length <= 20) {
+ out.push({
+ field,
+ label: field,
+ options: prop.enum.map((e) => String(e)).sort(),
+ })
+ continue
+ }
+ // Skip типы где dropdown не имеет смысла:
+ // - date/date-time/email/url: высокая cardinality, нужен picker/search
+ // - numbers (integer/number): range filter полезнее dropdown
+ if (
+ prop.format === 'date' ||
+ prop.format === 'date-time' ||
+ prop.format === 'email' ||
+ prop.format === 'url' ||
+ prop.type === 'integer' ||
+ prop.type === 'number' ||
+ prop.type === 'boolean' ||
+ prop.type === 'array' ||
+ prop.type === 'object'
+ ) {
+ continue
+ }
+ // FK (x-references) → use ALL unique values from records (target_dict
+ // values typically have low cardinality at usage site).
+ const isFk = typeof prop['x-references'] === 'string'
+ // String-typed без enum но с low cardinality в records.
+ if (prop.type === 'string' || isFk) {
+ const seen = new Set()
+ for (const r of records) {
+ const v = (r.data as Record)[field]
+ if (v !== null && v !== undefined && v !== '') {
+ seen.add(String(v))
+ if (seen.size > 12) break
+ }
+ }
+ if (seen.size > 0 && seen.size <= 12) {
+ out.push({
+ field,
+ label: field,
+ options: Array.from(seen).sort(),
+ })
+ }
+ }
+ }
+ return out.slice(0, 3) // Cap 3 filters max — не растянуть toolbar.
+ }, [schema, records])
+
+ if (filters.length === 0) return null
+
+ return (
+
+ {filters.map((f) => (
+
+ ))}
+
+ )
+}
diff --git a/ordinis-admin-ui/src/routes/dictionaries.index.tsx b/ordinis-admin-ui/src/routes/dictionaries.index.tsx
index 203bc96..ebdf6d5 100644
--- a/ordinis-admin-ui/src/routes/dictionaries.index.tsx
+++ b/ordinis-admin-ui/src/routes/dictionaries.index.tsx
@@ -19,7 +19,7 @@ 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 } from '@/api/queries'
+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'
@@ -174,15 +174,15 @@ function DictionariesPage() {
// Guest mode (anonymous) — кнопка «Создать справочник» скрыта. Backend всё
// равно вернёт 401 на POST, но UI hide избегает confusing UX.
const createButton = canMutate ? (
- }
onClick={() => setCreateOpen(true)}
- className="whitespace-nowrap"
+ 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"
>
+
{t('schema.action.create')}
-
+
) : null
if (isLoading) return
@@ -221,12 +221,34 @@ function DictionariesPage() {
className="space-y-3"
>
{/* Title block убран — "Справочники N шт." переехал в TopBar breadcrumb
- per user feedback ("эта надпись в топбаре"). Page начинается прямо с
- tinted toolbar bar, потом white table. */}
+ 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. */}
+
+
+ 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 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"
+ />
+
- {/* Toolbar in tinted bar per user feedback ("бар цветом").
- surface-2 (warm cream) визуально отделяет controls от table chrome. */}
-
{/* Scope filter: PUBLIC / INTERNAL / RESTRICTED — pill chips с border */}
toggleScope(scope)}
aria-pressed={selected}
title={t(`dict.list.section.${scope}`)}
- className={`text-cap tracking-[0.14em] uppercase font-semibold px-3 py-1.5 rounded-md border transition-colors focus:outline-none focus:ring-2 focus:ring-accent/40 ${
+ // 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
- ? `${SCOPE_BG_TINT[scope]} ${SCOPE_TEXT[scope]} border-transparent`
- : 'border-line bg-surface text-ink-2 hover:border-ink-2 hover:bg-surface-2'
+ ? `border-current shadow-sm`
+ : 'border-transparent opacity-70 hover:opacity-100'
}`}
>
{scope}
@@ -267,7 +292,7 @@ function DictionariesPage() {
type="button"
onClick={() => setBundle(undefined)}
aria-pressed={!bundleFilter}
- className={`text-body px-3 py-1.5 rounded-md border transition-colors focus:outline-none focus:ring-2 focus:ring-accent/40 ${
+ 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'
@@ -285,7 +310,7 @@ function DictionariesPage() {
type="button"
onClick={() => setBundle(selected ? undefined : bundle)}
aria-pressed={selected}
- className={`text-mono text-body px-3 py-1.5 rounded-md border transition-colors focus:outline-none focus:ring-2 focus:ring-accent/40 ${
+ 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'
@@ -308,7 +333,7 @@ function DictionariesPage() {
- {/* Empty state */}
+ {/* Empty state OR table — body of the panel (toolbar + body = one card). */}
{filtered.length === 0 ? (
-
+
{t('dict.list.search.empty')}
@@ -339,6 +364,9 @@ function DictionariesPage() {
)}
+
+ {/* === end catalog panel (toolbar + table) === */}
+
{
* 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 (
-
-
- {/* White header per user feedback ("заголовки таблицы белый фон").
- Прошлое bg-surface-2 сливалось с toolbar bar. */}
+ // No outer rounded/border wrapper — outer panel handles that (toolbar + table
+ // в одной card). Just table with white header per user feedback.
+
- |
- {t('dict.col.title', { defaultValue: 'Название' })}
- |
-
- {t('dict.col.id', { defaultValue: 'id' })}
- |
-
- {t('dict.col.bundle', { defaultValue: 'bundle' })}
- |
-
- {t('dict.col.scope', { defaultValue: 'scope' })}
- |
+ 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')}
+
{t('dict.col.records', { defaultValue: 'записей' })}
|
@@ -418,20 +500,48 @@ function DictionaryListTable({ rows }: { rows: DictionaryDefinition[] }) {
>
←
-
- {t('dict.col.updated', { defaultValue: 'изменён' })}
- |
+ toggleSort('updated')} active={sort.key === 'updated'}>
+ {t('dict.col.updated', { defaultValue: 'изменён' })}{arrow('updated')}
+
{/* Chevron col — affordance что row кликабелен (открывает editor) */}
|
- {rows.map((d) => (
+ {sortedRows.map((d) => (
))}
-
-
+
+ )
+}
+
+/**
+ * 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}
+ |
)
}
@@ -441,6 +551,18 @@ function DictRow({ d, t }: { d: DictionaryDefinition; t: TFunc }) {
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 это одинаково "далёкая дата").
@@ -522,9 +644,9 @@ function DictRow({ d, t }: { d: DictionaryDefinition; t: TFunc }) {
{d.scope}
- {/* records count */}
+ {/* records count — fetched per-row via useRecords (cached). */}
- {typeof d.recordCount === 'number' ? d.recordCount : '—'}
+ {recordCount !== undefined ? recordCount : '…'}
|
{/* outgoing FK count (proxy via schemaJson требует detail fetch; пока — placeholder) */}
— |
diff --git a/ordinis-admin-ui/src/routes/index.tsx b/ordinis-admin-ui/src/routes/index.tsx
index d08db86..2697f3a 100644
--- a/ordinis-admin-ui/src/routes/index.tsx
+++ b/ordinis-admin-ui/src/routes/index.tsx
@@ -1,7 +1,37 @@
-import { createFileRoute, redirect } from '@tanstack/react-router'
+import { createFileRoute, redirect, useNavigate } from '@tanstack/react-router'
+import { useEffect } from 'react'
+import { useAuth } from 'react-oidc-context'
export const Route = createFileRoute('/')({
- beforeLoad: () => {
+ beforeLoad: ({ location }) => {
+ // OIDC callback guard: когда Keycloak возвращает на `/?code=...&state=...`,
+ // AuthProvider (oidc-client-ts) обрабатывает params в useEffect и затем
+ // вызывает `onSigninCallback` который стрипает их через history.replaceState.
+ // Если router сразу redirect'нет на /dictionaries — code/state пропадут до
+ // того как AuthProvider их обработает → token exchange fail, user остаётся
+ // anonymous. Поэтому skip redirect когда есть ?code= в URL — компонент
+ // ниже сам redirect'нет после auth complete.
+ const raw = location.search as Record
+ if (raw && typeof raw.code === 'string') {
+ return
+ }
throw redirect({ to: '/dictionaries' })
},
+ component: HomeIndex,
})
+
+/**
+ * Renders only during OIDC callback (`/?code=...&state=...` от Keycloak).
+ * Waits for auth.isLoading=false → onSigninCallback вызвался → URL stripped
+ * → navigate to /dictionaries.
+ */
+function HomeIndex() {
+ const auth = useAuth()
+ const navigate = useNavigate()
+ useEffect(() => {
+ if (!auth.isLoading) {
+ void navigate({ to: '/dictionaries', replace: true })
+ }
+ }, [auth.isLoading, navigate])
+ return null
+}
diff --git a/ordinis-admin-ui/src/ui/components/language-switch.tsx b/ordinis-admin-ui/src/ui/components/language-switch.tsx
index 03787f6..719299f 100644
--- a/ordinis-admin-ui/src/ui/components/language-switch.tsx
+++ b/ordinis-admin-ui/src/ui/components/language-switch.tsx
@@ -39,7 +39,10 @@ export function LanguageSwitch({
role="radiogroup"
aria-label={ariaLabel ?? 'Language'}
className={cn(
- 'inline-flex items-center rounded-md border border-line bg-surface p-0.5 gap-0.5',
+ // h-8 box to match TopBar toolbar height. Buttons fill 100% height
+ // через inline-flex + items-center — никакого vertical jitter
+ // от text baseline vs icon baseline.
+ 'inline-flex items-stretch rounded-md border border-line bg-surface h-7 overflow-hidden',
className,
)}
>
@@ -55,7 +58,7 @@ export function LanguageSwitch({
onClick={() => onChange(v)}
title={opt.label}
className={cn(
- 'px-2 py-1 rounded-sm text-cap leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
+ 'inline-flex items-center justify-center px-2.5 text-cap leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
active
? 'bg-accent-bg text-accent'
: 'text-mute hover:text-ink hover:bg-surface-2',