Merge branch 'feat/dict-table-sort-columns' into 'main'
feat(ui): dict table column sort + visibility toggle (localStorage) See merge request 2-6/2-6-4/terravault/ordinis!282
This commit is contained in:
@@ -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}.
|
||||
*
|
||||
* <p>businessKey всегда скрыт из меню (primary identifier, обязательная).
|
||||
* Остальные (name, extra cols, scope, validFrom) могут быть hidden.
|
||||
*
|
||||
* <p>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 (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('dict.columns.menuLabel', { defaultValue: 'Колонки' })}
|
||||
title={t('dict.columns.menuLabel', { defaultValue: 'Колонки' })}
|
||||
className={cn(
|
||||
// size-9 = 36px touch target — на уровне других toolbar buttons.
|
||||
// Не 44 потому что toolbar height 32-36px и не хочется растягивать.
|
||||
'h-9 px-2 inline-flex items-center gap-1.5 rounded-md border border-line bg-surface text-cell text-ink-2',
|
||||
'hover:text-ink hover:bg-surface-2 transition-colors',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
)}
|
||||
>
|
||||
<ColumnsIcon size={14} weight="regular" />
|
||||
<span className="hidden sm:inline">
|
||||
{t('dict.columns.button', { defaultValue: 'Колонки' })}
|
||||
</span>
|
||||
<span className="text-mute tabular-nums">{visibleCount}/{total}</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-64 p-0">
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-line">
|
||||
<span className="text-cell text-mute">
|
||||
{t('dict.columns.title', { defaultValue: 'Видимость колонок' })}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={allVisible}
|
||||
onClick={onShowAll}
|
||||
className={cn(
|
||||
'text-cell',
|
||||
allVisible
|
||||
? 'text-mute cursor-not-allowed'
|
||||
: 'text-accent hover:underline',
|
||||
)}
|
||||
>
|
||||
{t('dict.columns.showAll', { defaultValue: 'Показать все' })}
|
||||
</button>
|
||||
</div>
|
||||
<ul className="py-1 max-h-72 overflow-y-auto">
|
||||
{columns.map((c) => {
|
||||
const isHidden = hidden.includes(c.key)
|
||||
const disabled = c.alwaysVisible
|
||||
return (
|
||||
<li key={c.key}>
|
||||
<label
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-3 py-1.5 text-body',
|
||||
disabled
|
||||
? 'text-mute cursor-not-allowed'
|
||||
: 'text-ink hover:bg-surface-2 cursor-pointer',
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={!isHidden}
|
||||
disabled={disabled}
|
||||
onChange={() => !disabled && onToggle(c.key)}
|
||||
aria-label={c.label}
|
||||
/>
|
||||
<span className="truncate flex-1">{c.label}</span>
|
||||
{disabled && (
|
||||
<span className="text-cap text-mute">
|
||||
{t('dict.columns.required', { defaultValue: 'обяз.' })}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -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).
|
||||
*
|
||||
* <p>Indicator:
|
||||
* <ul>
|
||||
* <li>none → серая двойная стрелка (CaretUpDown).</li>
|
||||
* <li>asc → акцентная стрелка вверх (CaretUp).</li>
|
||||
* <li>desc → акцентная стрелка вниз (CaretDown).</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Pass-through props у TableHeaderCell (hideBelow, align, aria-label).
|
||||
* Кликабельность — button-inside-cell, не сам cell — чтобы padding и
|
||||
* outline кликабельной зоны были корректными visually.
|
||||
*/
|
||||
|
||||
export interface SortableHeaderCellProps
|
||||
extends Omit<React.ComponentProps<typeof TableHeaderCell>, '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 (
|
||||
<TableHeaderCell {...cellProps}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSort(colKey)}
|
||||
aria-label={
|
||||
dir === 'asc'
|
||||
? `${children} (по возрастанию)`
|
||||
: dir === 'desc'
|
||||
? `${children} (по убыванию)`
|
||||
: `${children} (сортировать)`
|
||||
}
|
||||
aria-sort={dir === 'asc' ? 'ascending' : dir === 'desc' ? 'descending' : 'none'}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 -my-1 -mx-1 px-1 py-1 rounded-sm',
|
||||
'hover:text-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
active ? 'text-accent' : 'text-inherit',
|
||||
)}
|
||||
>
|
||||
<span>{children}</span>
|
||||
{dir === 'asc' ? (
|
||||
<CaretUpIcon size={12} weight="bold" />
|
||||
) : dir === 'desc' ? (
|
||||
<CaretDownIcon size={12} weight="bold" />
|
||||
) : (
|
||||
<CaretUpDownIcon size={12} weight="regular" className="text-mute/60 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
)}
|
||||
</button>
|
||||
</TableHeaderCell>
|
||||
)
|
||||
}
|
||||
@@ -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.
|
||||
@@ -0,0 +1,155 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
/**
|
||||
* Per-dict table state — column sort + visibility, persisted в localStorage.
|
||||
*
|
||||
* <p>Key format: {@code ord-dict-table:<dictName>} (один state на справочник).
|
||||
* Не делим по browser tab — пользователь хочет одинаковый layout везде где
|
||||
* открывал этот словарь. SSR-safe (guards `typeof window === 'undefined'`).
|
||||
*
|
||||
* <p>Sort: cycle {@code none → asc → desc → none} по той же колонке.
|
||||
* Переключение колонки начинает с {@code asc}. Reduce friction для
|
||||
* "посмотреть кто свежее / алфавит / scope grouping".
|
||||
*
|
||||
* <p>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<DictTableState>
|
||||
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<DictTableState>(() => 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<T>(
|
||||
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]
|
||||
}
|
||||
@@ -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<ColumnOption[]>(() => {
|
||||
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() {
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{/* Column visibility menu — persisted в localStorage per dict.
|
||||
* Скрывает любую non-essential колонку (всё кроме businessKey). */}
|
||||
<div className="ml-auto">
|
||||
<ColumnVisibilityMenu
|
||||
columns={columnOptions}
|
||||
hidden={dictTable.hidden}
|
||||
onToggle={dictTable.toggleHidden}
|
||||
onShowAll={() => dictTable.setHidden([])}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filteredRecords.length === 0 ? (
|
||||
@@ -1135,13 +1198,56 @@ function DictionaryDetail() {
|
||||
/>
|
||||
</TableHeaderCell>
|
||||
)}
|
||||
<TableHeaderCell>{t('dict.col.businessKey')}</TableHeaderCell>
|
||||
<TableHeaderCell hideBelow="md">name / code</TableHeaderCell>
|
||||
{extraColumns.map((c) => (
|
||||
<TableHeaderCell key={c.key} hideBelow="lg">{c.label}</TableHeaderCell>
|
||||
<SortableHeaderCell
|
||||
colKey="businessKey"
|
||||
sort={dictTable.sort}
|
||||
onSort={dictTable.toggleSort}
|
||||
>
|
||||
{t('dict.col.businessKey')}
|
||||
</SortableHeaderCell>
|
||||
{!dictTable.isHidden('name') && (
|
||||
<SortableHeaderCell
|
||||
colKey="name"
|
||||
hideBelow="md"
|
||||
sort={dictTable.sort}
|
||||
onSort={dictTable.toggleSort}
|
||||
>
|
||||
{t('dict.col.name', { defaultValue: 'name / code' })}
|
||||
</SortableHeaderCell>
|
||||
)}
|
||||
{extraColumns
|
||||
.filter((c) => !dictTable.isHidden(c.key))
|
||||
.map((c) => (
|
||||
<SortableHeaderCell
|
||||
key={c.key}
|
||||
colKey={c.key}
|
||||
hideBelow="lg"
|
||||
sort={dictTable.sort}
|
||||
onSort={dictTable.toggleSort}
|
||||
>
|
||||
{c.label}
|
||||
</SortableHeaderCell>
|
||||
))}
|
||||
<TableHeaderCell hideBelow="sm">{t('dict.col.scope')}</TableHeaderCell>
|
||||
<TableHeaderCell hideBelow="lg">{t('dict.col.validFrom')}</TableHeaderCell>
|
||||
{!dictTable.isHidden('scope') && (
|
||||
<SortableHeaderCell
|
||||
colKey="scope"
|
||||
hideBelow="sm"
|
||||
sort={dictTable.sort}
|
||||
onSort={dictTable.toggleSort}
|
||||
>
|
||||
{t('dict.col.scope')}
|
||||
</SortableHeaderCell>
|
||||
)}
|
||||
{!dictTable.isHidden('validFrom') && (
|
||||
<SortableHeaderCell
|
||||
colKey="validFrom"
|
||||
hideBelow="lg"
|
||||
sort={dictTable.sort}
|
||||
onSort={dictTable.toggleSort}
|
||||
>
|
||||
{t('dict.col.validFrom')}
|
||||
</SortableHeaderCell>
|
||||
)}
|
||||
{/* Actions column — без подписи per redesign prototype:
|
||||
* 3-dot trigger открывает menu с History / Edit / Close.
|
||||
* Aria-label на cell хедере для screen readers (visual: пусто). */}
|
||||
@@ -1199,13 +1305,17 @@ function DictionaryDetail() {
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
{!dictTable.isHidden('name') && (
|
||||
<TableCell hideBelow="md" className="min-w-[180px] max-w-[320px] truncate">
|
||||
{recordDisplayName(
|
||||
r.data,
|
||||
detailQuery.data?.defaultLocale ?? 'ru-RU',
|
||||
)}
|
||||
</TableCell>
|
||||
{extraColumns.map((c) => {
|
||||
)}
|
||||
{extraColumns
|
||||
.filter((c) => !dictTable.isHidden(c.key))
|
||||
.map((c) => {
|
||||
const v = r.data?.[c.key]
|
||||
if (v == null || v === '') {
|
||||
return (
|
||||
@@ -1230,14 +1340,18 @@ function DictionaryDetail() {
|
||||
</TableCell>
|
||||
)
|
||||
})}
|
||||
{!dictTable.isHidden('scope') && (
|
||||
<TableCell hideBelow="sm">
|
||||
<Badge variant="info">{r.dataScope}</Badge>
|
||||
</TableCell>
|
||||
)}
|
||||
{!dictTable.isHidden('validFrom') && (
|
||||
<TableCell hideBelow="lg">
|
||||
<span className="text-cell text-mute">
|
||||
{new Date(r.validFrom).toLocaleDateString()}
|
||||
</span>
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell align="right" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-end">
|
||||
{/* 3-dot DropdownMenu per redesign prototype. Заменяет
|
||||
|
||||
Reference in New Issue
Block a user