diff --git a/ordinis-admin-ui/src/components/dict/ColumnVisibilityMenu.tsx b/ordinis-admin-ui/src/components/dict/ColumnVisibilityMenu.tsx
new file mode 100644
index 0000000..c1b5462
--- /dev/null
+++ b/ordinis-admin-ui/src/components/dict/ColumnVisibilityMenu.tsx
@@ -0,0 +1,119 @@
+import { useTranslation } from 'react-i18next'
+import { ColumnsIcon } from '@phosphor-icons/react'
+import { Popover, PopoverTrigger, PopoverContent, Checkbox } from '@/ui'
+import { cn } from '@/lib/utils'
+
+/**
+ * Кнопка «Колонки» в toolbar таблицы записей. Открывает popover со списком
+ * available колонок + чекбоксы видимости. Mutations через onToggle —
+ * persisted в localStorage через {@link useDictTableState}.
+ *
+ *
businessKey всегда скрыт из меню (primary identifier, обязательная).
+ * Остальные (name, extra cols, scope, validFrom) могут быть hidden.
+ *
+ *
UX: counter "N/M выбрано" + кнопка «Все»/«Очистить» для quick toggles.
+ */
+
+export interface ColumnOption {
+ key: string
+ label: string
+ /** Если true — чекбокс disabled (e.g. businessKey всегда видим). */
+ alwaysVisible?: boolean
+}
+
+export interface ColumnVisibilityMenuProps {
+ columns: ColumnOption[]
+ hidden: string[]
+ onToggle: (col: string) => void
+ /** Quick action: show all columns. */
+ onShowAll: () => void
+}
+
+export function ColumnVisibilityMenu({
+ columns,
+ hidden,
+ onToggle,
+ onShowAll,
+}: ColumnVisibilityMenuProps) {
+ const { t } = useTranslation()
+ const togglable = columns.filter((c) => !c.alwaysVisible)
+ const visibleCount = togglable.filter((c) => !hidden.includes(c.key)).length
+ const total = togglable.length
+ const allVisible = visibleCount === total
+
+ return (
+
+
+
+
+
+
+
+ {t('dict.columns.title', { defaultValue: 'Видимость колонок' })}
+
+
+
+
+ {columns.map((c) => {
+ const isHidden = hidden.includes(c.key)
+ const disabled = c.alwaysVisible
+ return (
+ -
+
+
+ )
+ })}
+
+
+
+ )
+}
diff --git a/ordinis-admin-ui/src/components/dict/SortableHeaderCell.tsx b/ordinis-admin-ui/src/components/dict/SortableHeaderCell.tsx
new file mode 100644
index 0000000..18743a2
--- /dev/null
+++ b/ordinis-admin-ui/src/components/dict/SortableHeaderCell.tsx
@@ -0,0 +1,76 @@
+import * as React from 'react'
+import { CaretUpIcon, CaretDownIcon, CaretUpDownIcon } from '@phosphor-icons/react'
+import { TableHeaderCell } from '@/ui'
+import type { SortState } from '@/lib/useDictTableState'
+import { cn } from '@/lib/utils'
+
+/**
+ * Кликабельный заголовок колонки с indicator'ом sort state. Cycle через
+ * none → asc → desc → none. State приходит из {@link useDictTableState}
+ * (persisted в localStorage per dict).
+ *
+ *
Indicator:
+ *
+ * - none → серая двойная стрелка (CaretUpDown).
+ * - asc → акцентная стрелка вверх (CaretUp).
+ * - desc → акцентная стрелка вниз (CaretDown).
+ *
+ *
+ * Pass-through props у TableHeaderCell (hideBelow, align, aria-label).
+ * Кликабельность — button-inside-cell, не сам cell — чтобы padding и
+ * outline кликабельной зоны были корректными visually.
+ */
+
+export interface SortableHeaderCellProps
+ extends Omit, 'children' | 'onClick'> {
+ /** Идентификатор колонки в state.sort. */
+ colKey: string
+ /** Текст заголовка. */
+ children: React.ReactNode
+ /** Текущий sort state из useDictTableState. */
+ sort: SortState
+ /** Callback на click — useDictTableState.toggleSort. */
+ onSort: (col: string) => void
+}
+
+export function SortableHeaderCell({
+ colKey,
+ children,
+ sort,
+ onSort,
+ ...cellProps
+}: SortableHeaderCellProps) {
+ const active = sort?.col === colKey
+ const dir = active ? sort.dir : null
+
+ return (
+
+
+
+ )
+}
diff --git a/ordinis-admin-ui/src/lib/useDictTableState.test.ts b/ordinis-admin-ui/src/lib/useDictTableState.test.ts
new file mode 100644
index 0000000..03e14e4
--- /dev/null
+++ b/ordinis-admin-ui/src/lib/useDictTableState.test.ts
@@ -0,0 +1,58 @@
+import { describe, it, expect } from 'vitest'
+import { applySort } from './useDictTableState'
+
+describe('applySort', () => {
+ type Row = { id: string; name: string; age: number | null; ts: string }
+ const rows: Row[] = [
+ { id: 'b', name: 'Beta', age: 30, ts: '2026-01-02T00:00:00Z' },
+ { id: 'a', name: 'Alpha', age: null, ts: '2026-01-01T00:00:00Z' },
+ { id: 'c', name: 'gamma', age: 25, ts: '2026-01-03T00:00:00Z' },
+ ]
+ const get = (r: Row, col: string): unknown =>
+ col === 'id' ? r.id : col === 'name' ? r.name : col === 'age' ? r.age : r.ts
+
+ it('returns original when sort=null', () => {
+ expect(applySort(rows, null, get)).toEqual(rows)
+ })
+
+ it('sorts strings asc with numeric collation (case-insensitive)', () => {
+ const out = applySort(rows, { col: 'name', dir: 'asc' }, get).map((r) => r.id)
+ expect(out).toEqual(['a', 'b', 'c'])
+ })
+
+ it('sorts strings desc', () => {
+ const out = applySort(rows, { col: 'name', dir: 'desc' }, get).map((r) => r.id)
+ expect(out).toEqual(['c', 'b', 'a'])
+ })
+
+ it('puts null at the end for asc', () => {
+ const out = applySort(rows, { col: 'age', dir: 'asc' }, get).map((r) => r.id)
+ expect(out).toEqual(['c', 'b', 'a'])
+ })
+
+ it('puts null at the end for desc too', () => {
+ const out = applySort(rows, { col: 'age', dir: 'desc' }, get).map((r) => r.id)
+ expect(out[2]).toBe('a')
+ })
+
+ it('sorts numeric strings naturally (BK-10 > BK-2)', () => {
+ type R = { bk: string }
+ const items: R[] = [{ bk: 'BK-10' }, { bk: 'BK-2' }, { bk: 'BK-100' }]
+ const out = applySort(items, { col: 'bk', dir: 'asc' }, (r) => r.bk).map((r) => r.bk)
+ expect(out).toEqual(['BK-2', 'BK-10', 'BK-100'])
+ })
+
+ it('sorts ISO date strings asc (lex == chronological)', () => {
+ const out = applySort(rows, { col: 'ts', dir: 'asc' }, get).map((r) => r.id)
+ expect(out).toEqual(['a', 'b', 'c'])
+ })
+
+ it('does not mutate input', () => {
+ const before = [...rows]
+ applySort(rows, { col: 'name', dir: 'desc' }, get)
+ expect(rows).toEqual(before)
+ })
+})
+
+// useDictTableState localStorage interactions покрываются E2E (browser)
+// — здесь только applySort logic, чистая функция без DOM dependencies.
diff --git a/ordinis-admin-ui/src/lib/useDictTableState.ts b/ordinis-admin-ui/src/lib/useDictTableState.ts
new file mode 100644
index 0000000..5b5d2ac
--- /dev/null
+++ b/ordinis-admin-ui/src/lib/useDictTableState.ts
@@ -0,0 +1,155 @@
+import { useCallback, useEffect, useState } from 'react'
+
+/**
+ * Per-dict table state — column sort + visibility, persisted в localStorage.
+ *
+ * Key format: {@code ord-dict-table:} (один state на справочник).
+ * Не делим по browser tab — пользователь хочет одинаковый layout везде где
+ * открывал этот словарь. SSR-safe (guards `typeof window === 'undefined'`).
+ *
+ * Sort: cycle {@code none → asc → desc → none} по той же колонке.
+ * Переключение колонки начинает с {@code asc}. Reduce friction для
+ * "посмотреть кто свежее / алфавит / scope grouping".
+ *
+ *
Hidden: array column-key'ев которые юзер скрыл. {@code businessKey}
+ * forced visible на уровне UI (см. SortableHeaderCell — primary identifier,
+ * без него таблица бессмысленна). Прочие — optional.
+ */
+
+export type SortDir = 'asc' | 'desc'
+export type SortState = { col: string; dir: SortDir } | null
+
+export interface DictTableState {
+ sort: SortState
+ hidden: string[]
+}
+
+const DEFAULT_STATE: DictTableState = { sort: null, hidden: [] }
+
+const storageKey = (dictName: string): string => `ord-dict-table:${dictName}`
+
+const readState = (dictName: string): DictTableState => {
+ if (typeof window === 'undefined') return DEFAULT_STATE
+ try {
+ const raw = window.localStorage.getItem(storageKey(dictName))
+ if (!raw) return DEFAULT_STATE
+ const parsed = JSON.parse(raw) as Partial
+ return {
+ sort:
+ parsed.sort &&
+ typeof parsed.sort.col === 'string' &&
+ (parsed.sort.dir === 'asc' || parsed.sort.dir === 'desc')
+ ? { col: parsed.sort.col, dir: parsed.sort.dir }
+ : null,
+ hidden: Array.isArray(parsed.hidden)
+ ? parsed.hidden.filter((s): s is string => typeof s === 'string')
+ : [],
+ }
+ } catch {
+ return DEFAULT_STATE
+ }
+}
+
+const writeState = (dictName: string, state: DictTableState): void => {
+ if (typeof window === 'undefined') return
+ try {
+ window.localStorage.setItem(storageKey(dictName), JSON.stringify(state))
+ } catch {
+ /* QuotaExceeded / private mode — silently ignore, state остаётся
+ только в memory. */
+ }
+}
+
+export function useDictTableState(dictName: string) {
+ const [state, setState] = useState(() => readState(dictName))
+
+ // Перечитать когда смена dictName (navigation между справочниками).
+ useEffect(() => {
+ setState(readState(dictName))
+ }, [dictName])
+
+ useEffect(() => {
+ writeState(dictName, state)
+ }, [dictName, state])
+
+ /** Click на колонку: cycle через asc → desc → none.
+ * Click на другую колонку сначала: asc. */
+ const toggleSort = useCallback((col: string) => {
+ setState((prev) => {
+ const cur = prev.sort
+ if (!cur || cur.col !== col) return { ...prev, sort: { col, dir: 'asc' } }
+ if (cur.dir === 'asc') return { ...prev, sort: { col, dir: 'desc' } }
+ return { ...prev, sort: null }
+ })
+ }, [])
+
+ const setHidden = useCallback((hidden: string[]) => {
+ setState((prev) => ({ ...prev, hidden }))
+ }, [])
+
+ const toggleHidden = useCallback((col: string) => {
+ setState((prev) => {
+ const has = prev.hidden.includes(col)
+ const next = has
+ ? prev.hidden.filter((c) => c !== col)
+ : [...prev.hidden, col]
+ return { ...prev, hidden: next }
+ })
+ }, [])
+
+ const isHidden = useCallback(
+ (col: string): boolean => state.hidden.includes(col),
+ [state.hidden],
+ )
+
+ const reset = useCallback(() => {
+ setState(DEFAULT_STATE)
+ }, [])
+
+ return {
+ state,
+ sort: state.sort,
+ hidden: state.hidden,
+ toggleSort,
+ setHidden,
+ toggleHidden,
+ isHidden,
+ reset,
+ }
+}
+
+/** Helper для рендера: применяет sort к array записей по getter-функции.
+ * Null/empty значения всегда в конце независимо от direction (NULLS LAST
+ * для обоих asc/desc — стандартное SQL поведение, иначе reverse'нул бы
+ * пустоты наверх и заслонил полезные данные на первой странице). */
+export function applySort(
+ rows: T[],
+ sort: SortState,
+ getValue: (row: T, col: string) => unknown,
+): T[] {
+ if (!sort) return rows
+ const { col, dir } = sort
+ // Partition: nullish rows всегда в конце, valued — сортируем.
+ const valued: T[] = []
+ const nullish: T[] = []
+ for (const r of rows) {
+ const v = getValue(r, col)
+ if (v == null || v === '') nullish.push(r)
+ else valued.push(r)
+ }
+ valued.sort((a, b) => {
+ const av = getValue(a, col)
+ const bv = getValue(b, col)
+ if (typeof av === 'string' && typeof bv === 'string') {
+ // numeric:true → "BK-10" > "BK-2" корректно (для бизнес-ключей).
+ return av.localeCompare(bv, undefined, { numeric: true, sensitivity: 'base' })
+ }
+ if (typeof av === 'number' && typeof bv === 'number') {
+ return av - bv
+ }
+ // Дата ISO-string fallback — lex compare работает на ISO 8601.
+ return String(av).localeCompare(String(bv), undefined, { numeric: true })
+ })
+ if (dir === 'desc') valued.reverse()
+ return [...valued, ...nullish]
+}
diff --git a/ordinis-admin-ui/src/routes/dictionaries.$name.tsx b/ordinis-admin-ui/src/routes/dictionaries.$name.tsx
index 6fc57e1..3982995 100644
--- a/ordinis-admin-ui/src/routes/dictionaries.$name.tsx
+++ b/ordinis-admin-ui/src/routes/dictionaries.$name.tsx
@@ -61,6 +61,9 @@ import { UserCell } from '@/lib/useUserDisplay'
import { nowIsoLocal } from '@/lib/dates'
import { recordDisplayName } from '@/lib/locales'
import { SCOPE_BORDER_TOP } from '@/lib/scope-style'
+import { useDictTableState, applySort } from '@/lib/useDictTableState'
+import { ColumnVisibilityMenu, type ColumnOption } from '@/components/dict/ColumnVisibilityMenu'
+import { SortableHeaderCell } from '@/components/dict/SortableHeaderCell'
/**
* URL search params для filter state — share-friendly, persist между F5.
@@ -369,9 +372,39 @@ function DictionaryDetail() {
setPage(0)
}, [search, scopeFilter, aoi])
+ // Table state — sort (cycle none→asc→desc) + hidden columns persisted в
+ // localStorage per dict через useDictTableState.
+ const dictTable = useDictTableState(name)
+
+ // Getter для applySort — извлекает значение по col key из record.
+ // Изолирован чтобы sort работал унифицировано для всех типов колонок.
+ const recordColValue = useMemo(
+ () => (r: FlattenedRecord, col: string): unknown => {
+ if (col === 'businessKey') return r.businessKey
+ if (col === 'name') {
+ return recordDisplayName(
+ r.data,
+ detailQuery.data?.defaultLocale ?? 'ru-RU',
+ )
+ }
+ if (col === 'scope') return r.dataScope
+ if (col === 'validFrom') return r.validFrom
+ return r.data?.[col]
+ },
+ [detailQuery.data?.defaultLocale],
+ )
+
+ // Sort применяем к filteredRecords ДО pagination — иначе sort внутри
+ // страницы (например asc) показал бы только N сортированных значений из
+ // пула N+M, остальные на следующих страницах. Sort scope = filtered set.
+ const sortedRecords = useMemo(
+ () => applySort(filteredRecords, dictTable.sort, recordColValue),
+ [filteredRecords, dictTable.sort, recordColValue],
+ )
+
const visibleRecords = useMemo(
- () => filteredRecords.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE),
- [filteredRecords, page],
+ () => sortedRecords.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE),
+ [sortedRecords, page],
)
// Каноничный список видимых ключей — для select-all и для очистки selection
@@ -554,15 +587,19 @@ function DictionaryDetail() {
}
// Schema-driven extra columns: enum + reference поля — самые info-dense
- // не-локализованные scalar'ы. Берём первые 2 чтобы не перегрузить
- // строку (с businessKey + name + scope + extras + дата + actions = 6
- // колонок, comfortably fits на 1280px). Skip name/code/businessKey
- // (уже показаны), всё нескалярное (object/array/localized).
+ // не-локализованные scalar'ы. Skip name/code/businessKey (уже показаны),
+ // всё нескалярное (object/array/localized).
+ //
+ // Раньше cap = 2, чтобы строка comfortably fits на 1280px. Сейчас cap
+ // снят — все eligible колонки в pool, юзер выбирает через
+ // ColumnVisibilityMenu (стейт persisted в localStorage per dict).
+ // Soft cap 8 — защита от absurd schemas (50+ enum полей сломали бы
+ // toolbar layout до того как user успел зайти в menu).
const extraColumns = useMemo<{ key: string; label: string; isFk: boolean }[]>(() => {
const props = detailQuery.data?.schemaJson?.properties ?? {}
const interesting: { key: string; label: string; isFk: boolean }[] = []
for (const [key, prop] of Object.entries(props)) {
- if (interesting.length >= 2) break
+ if (interesting.length >= 8) break
if (key === 'name' || key === 'code' || key === 'businessKey') continue
const isLocalized = Boolean(prop['x-localized'])
if (isLocalized) continue
@@ -574,6 +611,22 @@ function DictionaryDetail() {
return interesting
}, [detailQuery.data?.schemaJson])
+ // Column registry: pool из которого юзер выбирает что показывать
+ // (см. ColumnVisibilityMenu в toolbar). businessKey alwaysVisible
+ // (primary identifier, без него nothing makes sense). Остальные —
+ // toggleable. Stored в localStorage через useDictTableState (объявлен
+ // выше, рядом с sortedRecords).
+ const columnOptions = useMemo(() => {
+ const opts: ColumnOption[] = [
+ { key: 'businessKey', label: t('dict.col.businessKey'), alwaysVisible: true },
+ { key: 'name', label: t('dict.col.name', { defaultValue: 'name / code' }) },
+ ...extraColumns.map((c) => ({ key: c.key, label: c.label })),
+ { key: 'scope', label: t('dict.col.scope') },
+ { key: 'validFrom', label: t('dict.col.validFrom') },
+ ]
+ return opts
+ }, [extraColumns, t])
+
const editingKey = edit.kind === 'edit' ? edit.record.businessKey : undefined
const rawRecordQuery = useRecordRaw(name, editingKey)
@@ -1107,6 +1160,16 @@ function DictionaryDetail() {
)}
+ {/* Column visibility menu — persisted в localStorage per dict.
+ * Скрывает любую non-essential колонку (всё кроме businessKey). */}
+
+ dictTable.setHidden([])}
+ />
+
{filteredRecords.length === 0 ? (
@@ -1135,13 +1198,56 @@ function DictionaryDetail() {
/>
)}
- {t('dict.col.businessKey')}
- name / code
- {extraColumns.map((c) => (
- {c.label}
- ))}
- {t('dict.col.scope')}
- {t('dict.col.validFrom')}
+
+ {t('dict.col.businessKey')}
+
+ {!dictTable.isHidden('name') && (
+
+ {t('dict.col.name', { defaultValue: 'name / code' })}
+
+ )}
+ {extraColumns
+ .filter((c) => !dictTable.isHidden(c.key))
+ .map((c) => (
+
+ {c.label}
+
+ ))}
+ {!dictTable.isHidden('scope') && (
+
+ {t('dict.col.scope')}
+
+ )}
+ {!dictTable.isHidden('validFrom') && (
+
+ {t('dict.col.validFrom')}
+
+ )}
{/* Actions column — без подписи per redesign prototype:
* 3-dot trigger открывает menu с History / Edit / Close.
* Aria-label на cell хедере для screen readers (visual: пусто). */}
@@ -1199,45 +1305,53 @@ function DictionaryDetail() {
) : null}
-
- {recordDisplayName(
- r.data,
- detailQuery.data?.defaultLocale ?? 'ru-RU',
- )}
-
- {extraColumns.map((c) => {
- const v = r.data?.[c.key]
- if (v == null || v === '') {
+ {!dictTable.isHidden('name') && (
+
+ {recordDisplayName(
+ r.data,
+ detailQuery.data?.defaultLocale ?? 'ru-RU',
+ )}
+
+ )}
+ {extraColumns
+ .filter((c) => !dictTable.isHidden(c.key))
+ .map((c) => {
+ const v = r.data?.[c.key]
+ if (v == null || v === '') {
+ return (
+
+ —
+
+ )
+ }
+ const text = String(v)
return (
- —
+
+ {text}
+
)
- }
- const text = String(v)
- return (
-
-
- {text}
-
-
- )
- })}
-
- {r.dataScope}
-
-
-
- {new Date(r.validFrom).toLocaleDateString()}
-
-
+ })}
+ {!dictTable.isHidden('scope') && (
+
+ {r.dataScope}
+
+ )}
+ {!dictTable.isHidden('validFrom') && (
+
+
+ {new Date(r.validFrom).toLocaleDateString()}
+
+
+ )}
e.stopPropagation()}>
{/* 3-dot DropdownMenu per redesign prototype. Заменяет