Files
mdm-ordinis/ordinis-admin-ui/src/routes/dictionaries.$name.tsx
T
2026-06-22 09:18:50 +00:00

3061 lines
130 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useState } from 'react'
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import axios from 'axios'
import {
Alert,
Badge,
Button,
Checkbox,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
EmptyState,
IconButton,
LoadingBlock,
Modal,
QueryErrorState,
SearchInput,
Table,
TableBody,
TableCell,
TableHead,
TableHeaderCell,
TableRow,
TextInput,
} from '@/ui'
import { PlusIcon, PencilSimpleIcon, XCircleIcon, GearIcon, ClockCounterClockwiseIcon, TrashIcon, CheckCircleIcon, WarningIcon, XIcon, CaretLeftIcon, CaretRightIcon, DotsThreeVerticalIcon, GitBranchIcon } from '@phosphor-icons/react'
import {
useAudit,
useChangelog,
useDictActiveSchemaDraft,
useDictionariesDeleted,
useDictionaryDetail,
useDictPendingDrafts,
useRecordRaw,
useRecords,
useScheduledSummary,
type RecordsFilter,
} from '@/api/queries'
import { useBulkCloseRecords, useBulkExportRecords, useCreateRecord, useUpdateRecord, useCloseRecord, useDeleteDictionary, useRestoreDictionary, useFormIdempotencyKey, useSubmitDraft } from '@/api/mutations'
import { useCanMutate } from '@/auth/useCanMutate'
import { useIsAdmin } from '@/auth/usePermissions'
import { cn } from '@/lib/utils'
import type { BulkCloseResponse, CreateRecordRequest, DataScope, FlattenedRecord } from '@/api/client'
import { SchemaDrivenForm } from '@/components/form/SchemaDrivenForm'
import { DictionaryEditorDialog } from '@/components/schema/DictionaryEditorDialog'
import { DictionaryHubView } from '@/components/lineage/DictionaryHubView'
import { CascadeConfirmDialog } from '@/components/lineage/CascadeConfirmDialog'
import { RecordHistoryDrawer } from '@/components/record/RecordHistoryDrawer'
import { RecordDrawer } from '@/components/record/RecordDrawer'
import { ConflictDiffModal } from '@/components/record/ConflictDiffModal'
import { WorkflowBanner } from '@/components/editor/WorkflowBanner'
import { EditorInfoPanel } from '@/components/editor/EditorInfoPanel'
import { ExportModal } from '@/components/editor/ExportModal'
import { AoiPickerDialog, type AoiResult } from '@/components/geo/AoiPickerDialog'
import { TimeTravelModal } from '@/components/timetravel/TimeTravelModal'
import { ChangelogDiffModal } from '@/components/changelog/ChangelogDiffModal'
import { kindBadgeVariant, kindIcon, kindLabel, isVersioningKind } from '@/components/changelog/kind-meta'
import { CreateSchemaDraftModal } from '@/components/workflow/CreateSchemaDraftModal'
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.
* - q: search query (free text)
* - scopes: csv subset of PUBLIC/INTERNAL/RESTRICTED. Empty/missing = все.
* - bbox: AOI как CSV "west,south,east,north". Polygon AOI в URL не
* сериализуем — слишком длинно (50+ точек), оставляем в local state.
* Если bbox задан — initial AOI установится в bbox-режим при mount'е.
*/
type DictSearch = {
q?: string
scopes?: string
bbox?: string
/** Time-travel: ISO datetime "просмотр на момент X". Если задан — backend
* findActiveAt(at) возвращает active version на этот момент вместо now(). */
at?: string
/** Active editor tab (Stage 3.4 handoff design):
* - records (default): table + filters + edit modal
* - relations: dependents graph (incoming FK refs + outgoing schema FKs)
* - fields: schema properties grid
* - json: raw schema JSON viewer
* - events: audit log filtered к этому dict
* - history: timeline записей changes
* Backward-compat: старый ?view=hub аутенитично remap'нется в ?tab=relations. */
tab?: 'records' | 'relations' | 'fields' | 'json' | 'events' | 'history'
}
const SCOPE_VALUES: ReadonlySet<DataScope> = new Set(['PUBLIC', 'INTERNAL', 'RESTRICTED'])
const validateSearch = (raw: Record<string, unknown>): DictSearch => {
const out: DictSearch = {}
if (typeof raw.q === 'string' && raw.q.length > 0) out.q = raw.q
if (typeof raw.scopes === 'string') {
const valid = raw.scopes
.split(',')
.map((s) => s.trim().toUpperCase())
.filter((s): s is DataScope => SCOPE_VALUES.has(s as DataScope))
if (valid.length > 0) out.scopes = valid.join(',')
}
if (typeof raw.bbox === 'string' && /^-?\d+(\.\d+)?(,-?\d+(\.\d+)?){3}$/.test(raw.bbox)) {
out.bbox = raw.bbox
}
// ISO datetime check: 2026-05-08T12:00:00Z или 2026-05-08T12:00:00+03:00.
if (typeof raw.at === 'string' && !isNaN(Date.parse(raw.at))) {
out.at = raw.at
}
// Tab param + backward-compat для старого ?view=hub.
const TABS: ReadonlySet<DictSearch['tab']> = new Set([
'records', 'relations', 'fields', 'json', 'events', 'history',
])
if (typeof raw.tab === 'string' && TABS.has(raw.tab as DictSearch['tab'])) {
out.tab = raw.tab as DictSearch['tab']
} else if (raw.view === 'hub') {
out.tab = 'relations'
}
return out
}
export const Route = createFileRoute('/dictionaries/$name')({
component: DictionaryDetail,
validateSearch,
})
type EditState =
| { kind: 'closed' }
| { kind: 'create' }
| { kind: 'edit'; record: FlattenedRecord }
| { kind: 'close-confirm'; record: FlattenedRecord }
function DictionaryDetail() {
const { name } = Route.useParams()
const urlSearch = Route.useSearch()
const navigate = useNavigate({ from: Route.fullPath })
const { t } = useTranslation()
const detailQuery = useDictionaryDetail(name)
const canMutate = useCanMutate()
// Phase 1c: show «Create draft» button only когда approvalRequired=true
// и нет active draft'а (иначе banner предложит open existing). Live (без
// approval flag) словари редактируются напрямую через EditorDialog —
// создавать draft не нужно.
const activeSchemaDraftQuery = useDictActiveSchemaDraft(name)
const shouldOfferCreateDraft =
detailQuery.data?.approvalRequired === true &&
!activeSchemaDraftQuery.data
// AOI: bbox из URL → bbox-AOI rendered initially; polygon AOI живёт
// только в local state (не сериализуем в URL — слишком длинно).
// setAoi записывает в URL bbox только для bbox-mode (полигон не жмём в URL).
const [aoi, setAoi] = useState<AoiResult | null>(() => bboxToAoi(urlSearch.bbox))
// Time-travel state — keep ISO string in URL так чтобы user мог share link
// "посмотри dict на 2025-12-31T00:00:00Z". Empty/undefined = now().
const timeTravelAt: string | undefined = urlSearch.at
const setTimeTravelAt = (next: string | undefined) => {
void navigate({
search: (prev) => ({ ...prev, at: next || undefined }),
replace: true,
})
}
const filter: RecordsFilter | undefined =
aoi || timeTravelAt
? {
...(aoi?.kind === 'bbox' ? { bbox: aoi.bboxCsv } : {}),
...(aoi?.kind === 'polygon' ? { polygon: aoi.geojson } : {}),
...(timeTravelAt ? { at: timeTravelAt } : {}),
}
: undefined
const recordsResult = useRecords(name, 'PUBLIC,INTERNAL,RESTRICTED', filter)
/**
* Scheduled records hint (post-Phase-B-2 UX fix). Когда records list пустой
* на момент now / time-travel, но в БД есть записи с validFrom > now,
* показываем «Запланировано N, ближайшая на DD.MM [Перейти к этой дате]».
* Fixes user feedback: «создал запись на 18.05 — её не видно».
*
* <p>Fetch unconditional (cheap single-aggregate query). Frontend сам решает
* показывать hint или нет по {@code count > 0}.
*/
const scheduledSummary = useScheduledSummary(name, 'PUBLIC,INTERNAL,RESTRICTED')
/**
* Approval Workflow v2 Phase 4: pending drafts на этот dict — для бейджа
* "На review" в каждой строке records table. Polled 30s. Bypass'нем если
* dict не approval-required (нет смысла грузить).
*/
// Гость (anonymous) — drafts polling требует JWT (401 каждые 30s в console).
// Disable если не авторизован.
const pendingDraftsQuery = useDictPendingDrafts(
canMutate && detailQuery.data?.approvalRequired ? name : undefined,
)
const pendingByKey = useMemo(() => {
const set = new Set<string>()
for (const d of pendingDraftsQuery.data ?? []) set.add(d.businessKey)
return set
}, [pendingDraftsQuery.data])
/**
* Set<businessKey> для записей имеющих scheduled future version. Используется
* рендерить «Запланировано» badge per row в records table — user сразу видит
* у каких записей есть upcoming изменения.
*/
const scheduledByKey = useMemo(() => {
const set = new Set<string>()
for (const bk of scheduledSummary.data?.scheduledBusinessKeys ?? []) set.add(bk)
return set
}, [scheduledSummary.data?.scheduledBusinessKeys])
const createMut = useCreateRecord(name)
const updateMut = useUpdateRecord(name)
// Approval Workflow v2 (D2=A): когда dict отмечен approvalRequired=true,
// backend rejects direct CRUD с 409 draft_required. Используем submit-draft
// путь автоматически — UX same form, но POST в /records/drafts вместо
// /records. Maker подаёт proposal, reviewer approve'нёт → запись применится.
const submitDraftMut = useSubmitDraft(name)
const approvalRequired = detailQuery.data?.approvalRequired === true
// Per-form idempotency key. Stable между ререндерами для одной сессии edit
// modal'а: двойной submit (Enter + click) не создаст 2 record'а — backend
// deduplicate'ит по тому же ключу. Reset на onSuccess.
const [recordIdempotencyKey, resetRecordIdempotencyKey] = useFormIdempotencyKey()
const closeMut = useCloseRecord(name)
const bulkCloseMut = useBulkCloseRecords(name)
const bulkExportMut = useBulkExportRecords(name)
// Delete dict mutation — admin-only через UI button в InfoPanel.
const deleteMut = useDeleteDictionary()
const isAdmin = useIsAdmin()
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false)
const [deleteError, setDeleteError] = useState<string | null>(null)
const [edit, setEdit] = useState<EditState>({ kind: 'closed' })
const [closeReason, setCloseReason] = useState('')
// Dirty-field count surfaced by SchemaDrivenForm → shown в RecordDrawer footer.
const [recordDirtyCount, setRecordDirtyCount] = useState(0)
// Drawer toolbar state per handoff Screen 3 line 279 (search + onlyFilled + counter).
const [drawerSearch, setDrawerSearch] = useState('')
const [drawerOnlyFilled, setDrawerOnlyFilled] = useState(false)
const [drawerCount, setDrawerCount] = useState<{ visible: number; total: number }>({
visible: 0,
total: 0,
})
const [schemaEditOpen, setSchemaEditOpen] = useState(false)
const [createDraftOpen, setCreateDraftOpen] = useState(false)
const [historyKey, setHistoryKey] = useState<string | undefined>(undefined)
const [aoiOpen, setAoiOpen] = useState(false)
const [timeTravelOpen, setTimeTravelOpen] = useState(false)
const [exportOpen, setExportOpen] = useState(false)
/** 409 save conflict: capture local payload + businessKey для diff modal.
* Открывается из createMut/updateMut onError когда status===409. */
const [conflict, setConflict] = useState<{
payload: CreateRecordRequest
businessKey: string
} | null>(null)
/** Phase 3 dict-relationships-v2: cascade dialog state. Открывается из 409
* на close, или явным action из record menu. */
const [cascadeKey, setCascadeKey] = useState<string | undefined>(undefined)
// Multi-select state. Set<businessKey> — стабильное id'шник. При изменении
// records data (mutation success / refetch / filter change) — selection
// не очищаем автоматически, но выкидываем те ключи которых больше нет
// в видимом фильтре (избегаем "невидимых" выбранных строк).
const [selection, setSelection] = useState<Set<string>>(new Set())
const [bulkOpen, setBulkOpen] = useState(false)
const [bulkReason, setBulkReason] = useState('')
const [bulkResult, setBulkResult] = useState<BulkCloseResponse | null>(null)
// Search & scope filter — URL-derived. Empty/missing = все scope активны.
const search = urlSearch.q ?? ''
const scopeFilter = useMemo<Set<DataScope>>(
() => new Set(urlSearch.scopes ? (urlSearch.scopes.split(',') as DataScope[]) : []),
[urlSearch.scopes],
)
const setSearch = (next: string) => {
void navigate({
search: (prev) => ({ ...prev, q: next.length > 0 ? next : undefined }),
replace: true,
})
}
// toggleScope удалён вместе с scope chips в records toolbar
// (заменены на RecordColumnFilters status/operator dropdowns).
// scopeFilter URL param остаётся read-only — backwards-compat shared links.
const handleAoiApply = (result: AoiResult) => {
setAoi(result)
// bbox в URL для share; polygon оставляем в memory.
void navigate({
search: (prev) => ({
...prev,
bbox: result.kind === 'bbox' ? result.bboxCsv : undefined,
}),
replace: true,
})
}
const handleAoiClear = () => {
setAoi(null)
void navigate({
search: (prev) => ({ ...prev, bbox: undefined }),
replace: true,
})
}
const handleClearFilters = () => {
setColumnFilters({})
void navigate({
search: (prev) => ({ ...prev, q: undefined, scopes: undefined }),
replace: true,
})
}
// Уникальные validFrom timestamps для timeline marks. Используем raw
// recordsResult (не filtered) чтобы видеть все версии независимо от scope/q
// фильтров. PLUS scheduled upcoming validFroms — user feedback: «на таймтревел
// отметки нужны где записи есть или меняются, а не просто +30 и ищи потом».
// После !220 расширили range до +30 дней, теперь добавляем future record
// validFroms как marks чтобы slider показывал «вот тут запись появится».
const recordTimestamps = useMemo(() => {
const set = new Set<number>()
for (const r of recordsResult.data ?? []) {
const ts = new Date(r.validFrom).getTime()
if (!Number.isNaN(ts)) set.add(ts)
}
for (const iso of scheduledSummary.data?.upcomingValidFroms ?? []) {
const ts = new Date(iso).getTime()
if (!Number.isNaN(ts)) set.add(ts)
}
return Array.from(set).sort((a, b) => a - b)
}, [recordsResult.data, scheduledSummary.data?.upcomingValidFroms])
// Column filters — per-column value match (e.g. status='operational',
// operator='Роскосмос'). Auto-derived из schema enum/x-references properties.
// Replaces прошлый scope chips filter в records toolbar per user feedback.
const [columnFilters, setColumnFilters] = useState<Record<string, string>>({})
const filteredRecords = useMemo(() => {
const raw = recordsResult.data ?? []
if (raw.length === 0) return raw
const q = search.trim().toLowerCase()
const scopes = scopeFilter
const colKeys = Object.entries(columnFilters)
return raw.filter((r) => {
if (scopes.size > 0 && !scopes.has(r.dataScope)) return false
// Column filters — exact match по data[field].
for (const [field, value] of colKeys) {
if (!value) continue
const v = (r.data as Record<string, unknown>)[field]
if (String(v ?? '') !== value) return false
}
if (q.length === 0) return true
// Search по businessKey + JSON.stringify(data) — простой,
// покрывает локализованные поля, code, описания. Stringify
// O(n) в memory, для 100 записей мгновенно.
if (r.businessKey.toLowerCase().includes(q)) return true
const haystack = JSON.stringify(r.data).toLowerCase()
return haystack.includes(q)
})
}, [recordsResult.data, search, scopeFilter, columnFilters])
const filtersActive =
search.length > 0 ||
scopeFilter.size > 0 ||
Object.values(columnFilters).some(Boolean)
const filteredCount = filteredRecords.length
// Client-side pagination — типичный словарь до 100 записей, server-side
// пейджинг overkill. Page 0-indexed. PAGE_SIZE = 50 — достаточно чтобы
// увидеть все основные dictionaries без листания.
const PAGE_SIZE = 50
const [page, setPage] = useState(0)
const totalPages = Math.max(1, Math.ceil(filteredCount / PAGE_SIZE))
// При изменении filter — на page 0 (иначе page может оказаться вне диапазона)
useEffect(() => {
setPage(0)
}, [search, scopeFilter, aoi])
// First-visit defaults: с info-dense schemas (10+ scalar полей) полная
// таблица не помещается в viewport → H-scroll. Hide хвост по умолчанию
// — показываем businessKey, name, scope, validFrom + первые 2 extra
// (обычно самые важные ключи). Юзер открывает menu Колонки и сам
// добавляет нужные — выбор persisted в localStorage и при следующем
// визите fallback уже не применяется.
const defaultHiddenExtras = useMemo<string[]>(() => {
const props = detailQuery.data?.schemaJson?.properties ?? {}
const eligible: string[] = []
for (const [key, prop] of Object.entries(props)) {
if (key === 'name' || key === 'code' || key === 'businessKey') continue
if (Boolean(prop['x-localized'])) continue
const t = prop.type
if (t === 'array' || t === 'object') continue
eligible.push(key)
}
return eligible.slice(2) // первые 2 видны, остальные default-hidden
}, [detailQuery.data?.schemaJson])
// Table state — sort (cycle none→asc→desc) + hidden columns persisted в
// localStorage per dict через useDictTableState.
const dictTable = useDictTableState(name, defaultHiddenExtras)
// 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(
() => sortedRecords.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE),
[sortedRecords, page],
)
// Каноничный список видимых ключей — для select-all и для очистки selection
// от ключей которые больше не видны (filter изменился, запись закрылась).
// С client-side pagination "видимые" = только текущая страница, чтобы
// select-all чекбокс трогал ровно то что юзер видит на экране.
const visibleKeys = useMemo(
() => visibleRecords.map((r) => r.businessKey),
[visibleRecords],
)
// Когда видимый набор сократился — выкидываем из selection невидимые ключи.
// Иначе bulk close может пытаться закрыть запись которую юзер уже не видит,
// что нарушает "WYSIWYG" принцип multi-select toolbar'а.
useEffect(() => {
setSelection((prev) => {
if (prev.size === 0) return prev
const visibleSet = new Set(visibleKeys)
const next = new Set<string>()
for (const k of prev) if (visibleSet.has(k)) next.add(k)
return next.size === prev.size ? prev : next
})
}, [visibleKeys])
const allVisibleSelected =
visibleKeys.length > 0 && visibleKeys.every((k) => selection.has(k))
const someVisibleSelected =
!allVisibleSelected && visibleKeys.some((k) => selection.has(k))
// Stage 3.9: Radix Checkbox принимает {@code checked="indeterminate"} как
// tri-state значение declaratively. Старый useEffect+ref hack (для нативного
// input.indeterminate JS-only prop) больше не нужен.
const selectAllChecked: boolean | 'indeterminate' = someVisibleSelected
? 'indeterminate'
: allVisibleSelected
// === Keyboard shortcuts per handoff Screen 2 line 438 ===
// j/k — move row cursor up/down
// Enter — open drawer для cursor row
// Esc — close drawer (handled Radix natively)
// n — open new-record drawer
// / — focus search (handled TopBar ⌘K shortcut; tab-style / focuses search)
//
// Cursor — index в visibleRecords. -1 когда нет focus (initial state).
const [rowCursor, setRowCursor] = useState(-1)
useEffect(() => {
const handler = (e: KeyboardEvent) => {
const target = e.target as HTMLElement
// Skip когда юзер в textarea/input — не мешаем typing.
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) return
if (target && target.isContentEditable) return
// Skip когда drawer / modal открыт (focus inside Radix portal).
if (edit.kind !== 'closed') return
// Skip если другие routes — ловим только на /dictionaries/$name тут.
if (e.key === 'j' || e.key === 'ArrowDown') {
e.preventDefault()
setRowCursor((c) => Math.min(visibleRecords.length - 1, c + 1))
} else if (e.key === 'k' || e.key === 'ArrowUp') {
e.preventDefault()
setRowCursor((c) => Math.max(0, c - 1))
} else if (e.key === 'Enter' && rowCursor >= 0 && rowCursor < visibleRecords.length) {
e.preventDefault()
setEdit({ kind: 'edit', record: visibleRecords[rowCursor] })
} else if (e.key === 'n' && canMutate) {
e.preventDefault()
setEdit({ kind: 'create' })
}
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [visibleRecords, rowCursor, edit.kind, canMutate])
// Reset cursor когда visibleRecords меняется (filter, page).
useEffect(() => {
setRowCursor(-1)
}, [page, search, scopeFilter, aoi])
const toggleSelect = (key: string) => {
setSelection((prev) => {
const next = new Set(prev)
if (next.has(key)) next.delete(key)
else next.add(key)
return next
})
}
const toggleSelectAll = () => {
setSelection((prev) => {
if (visibleKeys.every((k) => prev.has(k))) {
// Все видимые выбраны → снимаем именно их (не трогаем невидимые,
// если такие были; на практике их не должно быть благодаря useEffect)
const next = new Set(prev)
for (const k of visibleKeys) next.delete(k)
return next
}
const next = new Set(prev)
for (const k of visibleKeys) next.add(k)
return next
})
}
const clearSelection = () => setSelection(new Set())
const openBulkClose = () => {
setBulkResult(null)
setBulkReason('')
setBulkOpen(true)
}
// handleBulkExport больше не нужен — bulk export через InfoPanel "Экспорт..."
// → ExportModal автоматически pre-picks scope=selected когда selection непуст.
const [bulkDraftRunning, setBulkDraftRunning] = useState(false)
const handleBulkClose = async () => {
if (selection.size === 0) return
if (approvalRequired) {
// Approval Workflow v2: на approval-required dict bulk-close ходит
// не через POST /records/_bulk_close (вернёт 409 draft_required для
// каждого), а через N отдельных draft submissions (operation=CLOSE).
// Backend сейчас не имеет _bulk endpoint для drafts, делаем Promise.all
// на клиенте — N drafts создаются параллельно, результат собирается
// в тот же BulkCloseResponse shape.
setBulkDraftRunning(true)
const targets = Array.from(selection)
const result: BulkCloseResponse = { closed: [], skipped: [], errors: [] }
await Promise.all(
targets.map(async (bk) => {
try {
await submitDraftMut.mutateAsync({
businessKey: bk,
operation: 'CLOSE',
comment: bulkReason || null,
})
// 'closed' семантически здесь = 'submitted as CLOSE draft'.
// Frontend label показывает «отправлено на ревью» когда approvalRequired.
result.closed.push(bk)
} catch (e: unknown) {
const code =
(e as { response?: { data?: { code?: string } } })?.response
?.data?.code ??
(e as Error)?.message ??
'unknown'
// draft_already_pending — этот key уже на ревью, skip (не error).
if (code === 'draft_already_pending') {
result.skipped.push({ businessKey: bk, reason: code })
} else {
result.errors.push({ businessKey: bk, reason: code })
}
}
}),
)
setBulkResult(result)
setSelection((prev) => {
const next = new Set(prev)
for (const k of result.closed) next.delete(k)
return next
})
setBulkDraftRunning(false)
return
}
bulkCloseMut.mutate(
{
businessKeys: Array.from(selection),
reason: bulkReason || undefined,
},
{
onSuccess: (res) => {
setBulkResult(res)
// Снимаем selection с успешно закрытых; оставляем skipped/errors
// чтобы юзер видел что ещё не сделано.
setSelection((prev) => {
const next = new Set(prev)
for (const k of res.closed) next.delete(k)
return next
})
},
},
)
}
// Schema-driven extra columns: ВСЕ scalar non-localized поля schema'ы
// редактируемые в drawer'е — те же что и в edit-form. Юзер сам через
// ColumnVisibilityMenu выбирает что показывать (стейт persisted в
// localStorage per dict).
//
// Skip: name/code/businessKey (primary identifier уже отдельной колонкой),
// localized (требует locale-aware рендер, не подходит для compact pill),
// array/object (рендеримся как [object Object] — бесполезно в строке).
//
// Cap 30 — soft защита от absurd schemas. На практике справочники ЦУОД
// имеют 5-15 полей, drawer показывает их все — таблица теперь
// паритетна. FK маркеры (x-references) подсвечиваются accent цветом
// в строке row, остальные — нейтральный ink pill.
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 >= 30) break
if (key === 'name' || key === 'code' || key === 'businessKey') continue
if (Boolean(prop['x-localized'])) continue
const type = prop.type
// Array / object не рендерятся в pill — String([1,2,3]) = "1,2,3"
// ОК, но String({...}) = "[object Object]". Пропускаем оба.
if (type === 'array' || type === 'object') continue
const isFk = typeof prop['x-references'] === 'string' && Boolean(prop['x-references'])
interesting.push({ key, label: humanize(key), isFk })
}
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)
const handleSubmit = (req: CreateRecordRequest) => {
// Fill validFrom at submit (review #5): defaultValues в RHF берётся
// ТОЛЬКО при mount. Если user открыл modal в 12:00 и submit в 12:05 —
// `validFrom` в req это значение из 12:00. Backend check `validFrom >
// existingFrom` может fail если existing запись свежее. Submit-time
// refresh гарантирует что validFrom не устарел между mount и submit.
const submitReq: CreateRecordRequest = {
...req,
validFrom: req.validFrom && req.validFrom.length > 0 ? req.validFrom : nowIsoLocal(),
}
// 409 conflict handler — handoff line 444. Detect optimistic-lock
// mismatch and open ConflictDiffModal (diff editor's payload vs server
// current state). Other 409 codes (cascade, draft_required) обрабатываются
// отдельно — здесь только generic conflict ("entity changed concurrently").
const handleSaveError = (err: unknown, bk: string) => {
const status = (err as { response?: { status?: number } })?.response?.status
const code = (err as { response?: { data?: { code?: string } } })
?.response?.data?.code
if (status !== 409) return
// Specific 409 codes use their own dialogs — skip those.
if (
code === 'x_references_blocked_by_dependents' ||
code === 'x_references_cascade_required' ||
code === 'draft_required'
) {
return
}
// Generic 409 = optimistic-lock conflict. Open diff modal.
setConflict({ payload: submitReq, businessKey: bk })
}
if (edit.kind === 'create') {
if (approvalRequired) {
// Approval Workflow v2: POST /records/drafts вместо /records. Backend
// создаёт draft со status=PENDING, после approve reviewer'ом запись
// материализуется в live table. UX same form, разный endpoint.
submitDraftMut.mutate(
{
businessKey: submitReq.businessKey ?? '',
operation: 'CREATE',
data: submitReq.data,
geometryWkt: submitReq.geometryWkt ?? null,
validFrom: submitReq.validFrom ?? null,
validTo: submitReq.validTo ?? null,
},
{
onSuccess: () => {
setEdit({ kind: 'closed' })
resetRecordIdempotencyKey()
},
onError: (err) => handleSaveError(err, submitReq.businessKey ?? ''),
},
)
return
}
createMut.mutate(
{ payload: submitReq, idempotencyKey: recordIdempotencyKey },
{
onSuccess: () => {
setEdit({ kind: 'closed' })
resetRecordIdempotencyKey()
},
onError: (err) => {
// Create rarely conflicts (no prior record to lock), но если backend
// вернёт 409 по уникальному ключу — businessKey возьмём из payload.
handleSaveError(err, submitReq.businessKey ?? '')
},
},
)
return
}
if (edit.kind === 'edit') {
if (approvalRequired) {
// Approval Workflow v2: update тоже через draft (UPDATE proposal).
submitDraftMut.mutate(
{
businessKey: edit.record.businessKey,
operation: 'UPDATE',
data: submitReq.data,
geometryWkt: submitReq.geometryWkt ?? null,
validFrom: submitReq.validFrom ?? null,
validTo: submitReq.validTo ?? null,
},
{
onSuccess: () => {
setEdit({ kind: 'closed' })
resetRecordIdempotencyKey()
},
onError: (err) => handleSaveError(err, edit.record.businessKey),
},
)
return
}
updateMut.mutate(
{
businessKey: edit.record.businessKey,
payload: submitReq,
idempotencyKey: recordIdempotencyKey,
},
{
onSuccess: () => {
setEdit({ kind: 'closed' })
resetRecordIdempotencyKey()
},
onError: (err) => handleSaveError(err, edit.record.businessKey),
},
)
}
}
const handleClose = () => {
if (edit.kind !== 'close-confirm') return
const targetKey = edit.record.businessKey
if (approvalRequired) {
// Approval Workflow v2: close тоже через draft (operation=CLOSE).
// Maker подаёт CLOSE proposal, reviewer'у достаточно approve и запись
// закроется автоматически через recordService.applyApprovedClose.
submitDraftMut.mutate(
{
businessKey: targetKey,
operation: 'CLOSE',
comment: closeReason || null,
},
{
onSuccess: () => {
setEdit({ kind: 'closed' })
setCloseReason('')
},
},
)
return
}
closeMut.mutate(
{ businessKey: targetKey, reason: closeReason || undefined },
{
onSuccess: () => {
setEdit({ kind: 'closed' })
setCloseReason('')
},
onError: (err: unknown) => {
// Phase 3 dict-relationships-v2: 409 с cascade-related code
// открывает CascadeConfirmDialog. Иначе error inline в close-confirm.
const status =
(err as { response?: { status?: number } })?.response?.status
const code = (err as { response?: { data?: { code?: string } } })
?.response?.data?.code
if (
status === 409 &&
(code === 'x_references_blocked_by_dependents' ||
code === 'x_references_cascade_required')
) {
// Reset closeMut.error до открытия cascade dialog'а — иначе stale
// 409 mesg может проскочить в открытом cascade UI пока user
// ещё не сделал retry (review #18).
closeMut.reset()
setEdit({ kind: 'closed' })
setCloseReason('')
setCascadeKey(targetKey)
}
},
},
)
}
// Approval Workflow v2: когда dict approval-required — мы делаем submit
// через submitDraftMut, поэтому activeMutation должен указывать на него,
// иначе spinner / error из createMut/updateMut выйдут несинхронно.
const activeMutation = (() => {
if (edit.kind === 'create') return approvalRequired ? submitDraftMut : createMut
if (edit.kind === 'edit') return approvalRequired ? submitDraftMut : updateMut
return null
})()
const serverError = serverErrorMessage(activeMutation?.error)
const totalRecords = recordsResult.data?.length ?? 0
const scope = detailQuery.data?.scope
// Geo capability detection — для скрытия AOI button на non-geo dicts.
// Эвристика по schema properties: property name matches geom/location/
// bbox/polygon/coordinates, OR format=geojson. Не идеально (false negatives
// если backend хранит geometry separately в записи без schema mention),
// но в practice все geo-dicts имеют geometry property в schemaJson.
const hasGeoRecords = useMemo(() => {
const props = detailQuery.data?.schemaJson?.properties
if (!props) return false
for (const [key, p] of Object.entries(props)) {
if (/geom|location|bbox|polygon|coordinates|geo/i.test(key)) return true
if (p.format === 'geojson' || p.format === 'wkt') return true
}
return false
}, [detailQuery.data?.schemaJson])
// Full-page loader пока ОБА query (detail + records) не готовы. Иначе
// юзер видит partial render — узкая колонка без extra columns, info panel
// ещё не появилась, бар нагружается частями. Per user: "вот такого не
// должно быть, лучше лоадер".
if (detailQuery.isLoading || (!detailQuery.data && !detailQuery.error)) {
return (
<div className="min-h-[60vh] flex items-center justify-center">
<LoadingBlock size="lg" label={t('loading')} />
</div>
)
}
if (detailQuery.error) {
return (
<DictionaryErrorOrSoftDeletedCard
name={name}
error={detailQuery.error}
isAdmin={isAdmin}
onRetry={() => detailQuery.refetch()}
/>
)
}
return (
<div className="space-y-6">
{scope && (
<div
className={`-mt-4 sm:-mt-6 -mx-4 sm:-mx-6 mb-2 h-1 border-t-4 ${SCOPE_BORDER_TOP[scope]}`}
aria-hidden="true"
/>
)}
{/* PageHeader removed per redesign: title/version вынесены в TopBar
* breadcrumb (см. components/layout/TopBar.tsx). Actions перенесены
* в InfoPanel (Time-travel, AOI) + tab toolbar (Schema, +Запись). */}
{/* 3-col layout per handoff prototype design/compact.html:
[Sidebar (220px global)] [InfoPanel 220px] [main content]
Info panel — sticky слева с dict metadata + relations.
На <lg viewport schedule переключается в stacked (info above content). */}
{/* Grid items-stretch (default) — InfoPanel + main panel равной высоты.
* gap-3 — closer panels per user feedback ("ближе расположены").
* QA bug #1: 1fr → minmax(0,1fr) — без minmax(0,…) grid track имеет
* implicit min-content (auto), что позволяет records table вырасти
* шире viewport. После закрытия Radix Dialog снимается body padding-
* right (~15px), 1fr пересчитывается и ломает layout (исчезает
* первая колонка под overflow-hidden card). minmax(0,1fr) фиксирует
* нижнюю границу 0 → overflow-x-auto в Table primitive работает. */}
<div className="grid grid-cols-1 lg:grid-cols-[220px_minmax(0,1fr)] gap-3 mt-2">
{detailQuery.data && (
<EditorInfoPanel
detail={detailQuery.data}
recordCount={totalRecords}
banner={
// WorkflowBanner живёт в InfoPanel per user feedback ("плашки
// пусть в левой панели будут") — освобождает horizontal space
// editor'а и concentrates status info в одном месте.
<WorkflowBanner detail={detailQuery.data} pendingCount={pendingByKey.size} />
}
actions={{
onTimeTravel: () => setTimeTravelOpen((v) => !v),
timeTravelActive: Boolean(timeTravelAt),
// AOI button показываем только если у этого dict есть geo data —
// хотя бы одна record имеет geometryWkt. На non-geo dicts кнопка
// лишняя. Per user feedback: "aoi там где нет полей гео лишний".
// Edge case: если active aoi уже set → показываем чтобы юзер мог clear.
onAoi: hasGeoRecords || aoi ? () => setAoiOpen(true) : undefined,
aoiActive: Boolean(aoi),
// Открываем ExportModal вместо immediate triggera — пользователь
// выбирает формат/scope/columns/encoding/delimiter.
onExport: () => setExportOpen(true),
exportPending: bulkExportMut.isPending,
// Удалить справочник — только админ. Cascade delete на
// records/drafts/audit (FK CASCADE migration 0030).
// Confirmation modal проверяет dependents через backend
// и предлагает force option если нужно.
onDelete: isAdmin ? () => setDeleteConfirmOpen(true) : undefined,
deletePending: deleteMut.isPending,
}}
/>
)}
<div className="min-w-0 space-y-4">
{/* WorkflowBanner перенесён в InfoPanel banner slot — освобождает
* horizontal space здесь, concentrates все status notices в leftpanel. */}
{/* Main editor panel — tab bar + toolbar + tab content в bordered card
* per redesign prototype. Visual containment отделяет editor от
* page chrome.
* QA bug #1: overflow-hidden убран — клиппил records table при ширине
* контента больше колонки. Table primitive сам имеет overflow-x-auto;
* tabs ниже получают свой scroll контейнер. Card rounding продолжает
* работать через border-radius на самом div'е. */}
<div className="rounded-lg border border-line bg-surface">
{/* Editor tabs per handoff Stage 3.4 + redesign:
* - counts в labels (Записи 127, Связи 6, Поля 12)
* - right-aligned actions: Schema edit + +Запись per редизайн */}
{detailQuery.data && (
<EditorTabBar
active={urlSearch.tab ?? 'records'}
onChange={(tab) =>
void navigate({
search: (prev) => ({
...prev,
tab: tab === 'records' ? undefined : tab,
// QA UX-003: сбросить records-tab фильтры (search/q + column filters
// + scopes) когда пользователь перешёл на другую вкладку. Иначе
// search:'абв' оставался применённым после возврата на records.
q: undefined,
scopes: undefined,
}),
replace: true,
})
}
counts={{
records: totalRecords,
relations: (detailQuery.data ? extractOutgoingFkCount(detailQuery.data.schemaJson) : 0),
fields: Object.keys(detailQuery.data?.schemaJson?.properties ?? {}).length,
}}
actions={
canMutate ? (
<>
{/* approval-required dict без active draft: предлагаем
создать черновик. Direct edit через GearIcon оставляем
для админов, но primary path — через workflow. */}
{shouldOfferCreateDraft && (
<IconButton
type="button"
label={t('createDraft.openButton', {
defaultValue: 'Создать черновик схемы',
})}
variant="default"
icon={<GitBranchIcon weight="regular" />}
disabled={!detailQuery.data}
onClick={() => setCreateDraftOpen(true)}
/>
)}
<IconButton
type="button"
label={t('schema.action.editSchema')}
variant="default"
icon={<GearIcon weight="regular" />}
disabled={!detailQuery.data}
onClick={() => setSchemaEditOpen(true)}
/>
<Button
type="button"
variant="primary"
size="sm"
leftIcon={<PlusIcon weight="bold" size={14} />}
disabled={!detailQuery.data}
onClick={() => setEdit({ kind: 'create' })}
className="whitespace-nowrap"
>
{t('dict.action.create')}
</Button>
</>
) : undefined
}
/>
)}
{/* Relations tab — incoming FK + outgoing schema FK. */}
{urlSearch.tab === 'relations' && detailQuery.data && (
<DictionaryHubView detail={detailQuery.data} />
)}
{/* Fields tab — schema properties grid. */}
{urlSearch.tab === 'fields' && detailQuery.data && (
<FieldsTabContent detail={detailQuery.data} />
)}
{/* JSON tab — raw schema viewer (read-only). */}
{urlSearch.tab === 'json' && detailQuery.data && (
<JsonTabContent detail={detailQuery.data} />
)}
{/* Events tab — link to /audit с pre-filled dict filter. */}
{urlSearch.tab === 'events' && (
<EventsTabContent dictName={name} />
)}
{/* History tab — schema versions timeline. HEAD row из current detail;
backend changelog endpoint pending для исторических versions. */}
{urlSearch.tab === 'history' && (
<HistoryTabContent detail={detailQuery.data} />
)}
{/* DictionaryDependentsPanel inline-card удалён — reverse FK rows
* теперь живут в EditorInfoPanel ← используют (rich rows с field path
* + active count + onClose policy chip). Один source of truth. */}
{aoi && (
<div className="flex items-center gap-3 px-3 py-2 rounded-sm border border-accent/30 bg-accent/4 text-body">
<span className=" text-mono text-ink">
{aoi.kind === 'bbox'
? t('aoi.activeBbox', { value: aoi.bboxCsv })
: t('aoi.activePolygon', {
points: aoi.geojson.coordinates[0]?.length ?? 0,
})}
</span>
<button
type="button"
className="text-cell text-accent hover:underline"
onClick={handleAoiClear}
>
{t('aoi.clear')}
</button>
</div>
)}
{/* === Records tab content (default, also fallback if no ?tab) === */}
{(!urlSearch.tab || urlSearch.tab === 'records') && (
<>
{/* Time-travel modal — only mount когда open=true. Modal внутри
использует Radix Dialog который portals content при open. Но props
like `marks={recordTimestamps}` пересоздаются на каждый render
родителя (new array reference) — это нормально для уже mounted
компонента, но при always-mounted причиняет нестабильность store.
Pattern: lazy mount только если open. */}
{timeTravelOpen && detailQuery.data && (
<TimeTravelModal
open
onClose={() => setTimeTravelOpen(false)}
detail={detailQuery.data}
value={timeTravelAt}
onApply={(iso) => setTimeTravelAt(iso)}
onClear={() => setTimeTravelAt(undefined)}
marks={recordTimestamps}
totalRecords={totalRecords}
/>
)}
{/* Active state — ambient banner если ?at= задан, даже если picker закрыт. */}
{timeTravelAt && !timeTravelOpen && (
<div className="flex items-center gap-3 px-3 py-2 rounded-sm border border-warn bg-warn-bg text-body">
<ClockCounterClockwiseIcon weight="bold" size={16} className="text-warn shrink-0" />
<span className=" text-mono text-ink">
{t('timeTravel.viewing', {
date: new Date(timeTravelAt).toLocaleString(),
})}
</span>
<button
type="button"
className="text-cell text-accent hover:underline ml-auto"
onClick={() => setTimeTravelAt(undefined)}
>
{t('timeTravel.clear')}
</button>
</div>
)}
{recordsResult.isLoading && <LoadingBlock size="md" label={t('loading')} />}
{recordsResult.error && (
<QueryErrorState
error={recordsResult.error}
onRetry={() => recordsResult.refetch()}
/>
)}
{recordsResult.data && recordsResult.data.length === 0 && (
<EmptyState
title={t('dict.empty')}
description={
scheduledSummary.data && scheduledSummary.data.count > 0 && scheduledSummary.data.nearestValidFrom
? t('dict.scheduled.hint', {
count: scheduledSummary.data.count,
date: new Date(scheduledSummary.data.nearestValidFrom).toLocaleDateString(),
})
: undefined
}
action={
scheduledSummary.data && scheduledSummary.data.count > 0 && scheduledSummary.data.nearestValidFrom
? (
<button
type="button"
onClick={() => setTimeTravelAt(scheduledSummary.data!.nearestValidFrom!)}
className="text-cell text-accent hover:underline"
>
{t('dict.scheduled.cta')}
</button>
)
: undefined
}
/>
)}
{recordsResult.data && recordsResult.data.length > 0
&& scheduledSummary.data && scheduledSummary.data.count > 0
&& scheduledSummary.data.nearestValidFrom && (
// Info banner для non-empty listing когда есть scheduled records.
// User feedback: «нету отметки что есть запись в будущем» — table
// shows только currently active, юзер не видит upcoming. Banner
// duplicates EmptyState hint в non-empty case + CTA jump-to-date.
<div className="flex items-center gap-3 px-3 py-2 mb-2 rounded-sm border border-warn bg-warn-bg text-body">
<ClockCounterClockwiseIcon weight="bold" size={16} className="text-warn shrink-0" />
<span className="text-cell text-ink">
{t('dict.scheduled.hint', {
count: scheduledSummary.data.count,
date: new Date(scheduledSummary.data.nearestValidFrom).toLocaleDateString(),
})}
</span>
<button
type="button"
onClick={() => setTimeTravelAt(scheduledSummary.data!.nearestValidFrom!)}
className="text-cell text-accent hover:underline ml-auto"
>
{t('dict.scheduled.cta')}
</button>
</div>
)}
{recordsResult.data && recordsResult.data.length > 0 && (
<>
{/* Records toolbar — attached к table panel (tinted top section,
* border-b separator от table body). User feedback: "бар таблицы
* не должен быть оторван от нее" — applied к catalog в MR 88,
* теперь и в editor records tab. */}
<div className="flex flex-wrap items-center gap-3 bg-surface-2 border-b border-line px-3 py-2">
<div className="flex-1 min-w-[240px] max-w-md">
<SearchInput
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('dict.filter.searchPlaceholder')}
aria-label={t('dict.filter.searchPlaceholder')}
/>
</div>
{/* Column filter dropdowns per redesign — auto-detect filterable
* columns (status, enum-typed fields). User feedback: "фильтр в
* справочнике по статусу скорее нужен чем по scope". Scope chips
* убраны — scope viewable в SCOPE column, less useful as filter. */}
<RecordColumnFilters
schema={detailQuery.data?.schemaJson}
records={recordsResult.data ?? []}
value={columnFilters}
onChange={setColumnFilters}
/>
{filtersActive && (
<div className="flex items-center gap-2 text-cell text-ink-2">
<span>
{t('dict.filter.matched', { count: filteredCount, total: totalRecords })}
</span>
<button
type="button"
onClick={handleClearFilters}
className="text-accent hover:underline"
>
{t('dict.filter.clear')}
</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 ? (
<EmptyState title={t('dict.filter.noMatches')} />
) : (
<>
{canMutate && selection.size > 0 && (
<BulkSelectionToolbar
count={selection.size}
totalVisible={filteredCount}
onBulkClose={openBulkClose}
onClear={clearSelection}
/>
)}
{/* No <Panel> wrap — outer editor panel handles border. Direct
* table → continuous visual flow toolbar→table в one card.
* TableHead sticky top: при page V-scroll header не уезжает,
* sort/aria всегда видны. z-10 чтобы над cells. bg-surface
* чтобы строки под header не просвечивали при scroll. */}
<Table>
<TableHead className="sticky top-0 z-10 bg-surface">
<TableRow>
{canMutate && (
<TableHeaderCell>
<Checkbox
aria-label={t('dict.bulk.selectAll')}
checked={selectAllChecked}
onChange={toggleSelectAll}
/>
</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>
))}
{!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: пусто). */}
<TableHeaderCell align="right" aria-label={t('dict.col.actions')}>
<span className="sr-only">{t('dict.col.actions')}</span>
</TableHeaderCell>
</TableRow>
</TableHead>
<TableBody>
{visibleRecords.map((r, idx) => (
<TableRow
key={r.id}
data-selected={selection.has(r.businessKey)}
// Row-wide click → open edit drawer (только для canMutate).
// Anonymous users — read-only, click no-op чтобы не путать.
// Bubbling: nested buttons (checkbox, action icons) делают
// stopPropagation чтобы они не открывали drawer случайно.
onClick={canMutate ? () => setEdit({ kind: 'edit', record: r }) : undefined}
className={[
canMutate ? 'cursor-pointer' : '',
rowCursor === idx ? 'bg-accent-bg/40 outline outline-1 outline-accent/30' : '',
].filter(Boolean).join(' ') || undefined}
>
{canMutate && (
<TableCell onClick={(e) => e.stopPropagation()}>
<Checkbox
aria-label={`${t('dict.bulk.selectRow')} ${r.businessKey}`}
checked={selection.has(r.businessKey)}
onChange={() => toggleSelect(r.businessKey)}
/>
</TableCell>
)}
<TableCell>
<div className="flex items-center gap-1.5">
{/* Business key — accent color per prototype (mono accent,
* primary identifier для каждой row). */}
<span className="text-mono text-accent">{r.businessKey}</span>
{pendingByKey.has(r.businessKey) ? (
<span title={t('dict.pendingReview.tooltip')}>
<Badge variant="warning">{t('dict.pendingReview.label')}</Badge>
</span>
) : null}
{/* «Scheduled» badge: у этой записи есть future version
* (validFrom > now). User видит в общем списке какие
* записи скоро изменятся. CTA для перехода к этой дате
* — banner выше уже линкует на nearestValidFrom. */}
{scheduledByKey.has(r.businessKey) ? (
<span title={t('dict.scheduled.row.tooltip', {
defaultValue: 'У записи есть запланированная будущая версия',
})}>
<Badge variant="info">
{t('dict.scheduled.row.badge', { defaultValue: 'Запланировано' })}
</Badge>
</span>
) : 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
.filter((c) => !dictTable.isHidden(c.key))
.map((c) => {
const v = r.data?.[c.key]
if (v == null || v === '') {
return (
<TableCell key={c.key} hideBelow="lg">
<span className="text-cell text-mute/70"></span>
</TableCell>
)
}
const text = String(v)
return (
<TableCell key={c.key} hideBelow="lg">
<span
className={[
'inline-block px-1.5 py-0.5 rounded-sm font-mono text-cell',
c.isFk
? 'bg-accent/8 text-accent'
: 'bg-ink/8 text-ink',
].join(' ')}
>
{text}
</span>
</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. Заменяет
* inline icon trio (history / edit / close). Trigger:
* 36px touch target (tablet-safe). Items: History,
* Edit, Close (Edit/Close — только для canMutate). */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
aria-label={t('dict.col.actions')}
className="size-9 inline-flex items-center justify-center rounded-md text-mute hover:text-ink hover:bg-surface-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring transition-colors"
>
<DotsThreeVerticalIcon weight="bold" size={18} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[180px]">
<DropdownMenuItem onSelect={() => setHistoryKey(r.businessKey)}>
<ClockCounterClockwiseIcon weight="regular" size={16} className="mr-2 text-ink-2" />
{t('history.title')}
</DropdownMenuItem>
{canMutate && (
<>
<DropdownMenuItem onSelect={() => setEdit({ kind: 'edit', record: r })}>
<PencilSimpleIcon weight="regular" size={16} className="mr-2 text-ink-2" />
{t('dict.action.edit')}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => setEdit({ kind: 'close-confirm', record: r })}
className="text-mars focus:text-mars focus:bg-mars/8"
>
<XCircleIcon weight="regular" size={16} className="mr-2" />
{t('dict.action.close')}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{totalPages > 1 && (
// Sticky bottom: pagination всегда в viewport при V-scroll
// длинной таблицы — не надо листать вниз чтобы перейти на
// page 2. bg-surface + border-top чтобы визуально отделить
// от scrolling tbody.
<div className="sticky bottom-0 z-10 bg-surface border-t border-line">
<RecordsPagination
page={page}
totalPages={totalPages}
totalCount={filteredCount}
pageSize={PAGE_SIZE}
onPrev={() => setPage((p) => Math.max(0, p - 1))}
onNext={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
/>
</div>
)}
</>
)}
</>
)}
</>
)}
{/* === end records tab content === */}
</div>
{/* === end editor main panel === */}
</div>
</div>
{/* === end 3-col grid === */}
<RecordDrawer
open={edit.kind === 'create' || edit.kind === 'edit'}
onClose={() => {
setEdit({ kind: 'closed' })
setRecordDirtyCount(0)
setDrawerSearch('')
setDrawerOnlyFilled(false)
}}
caption={
edit.kind === 'edit'
? approvalRequired
? t('drawer.captionEditDraft', {
dict: name,
defaultValue: `${name} — proposal на изменение (через ревью)`,
})
: t('drawer.captionEdit', { dict: name })
: approvalRequired
? t('drawer.captionCreateDraft', {
dict: name,
defaultValue: `${name} — proposal на создание (через ревью)`,
})
: t('drawer.captionCreate', { dict: name })
}
recordId={edit.kind === 'edit' ? edit.record.businessKey : undefined}
formId="record-form"
dirtyCount={recordDirtyCount}
isPending={Boolean(activeMutation?.isPending)}
// Toolbar показывается только в edit mode где filters имеют смысл.
// Create mode — все поля пустые, "только заполненные" даст empty list.
toolbar={
edit.kind === 'edit'
? {
search: drawerSearch,
onSearchChange: setDrawerSearch,
onlyFilled: drawerOnlyFilled,
onOnlyFilledChange: setDrawerOnlyFilled,
counterVisible: drawerCount.visible,
counterTotal: drawerCount.total,
}
: undefined
}
onDelete={
edit.kind === 'edit'
? () => setEdit({ kind: 'close-confirm', record: edit.record })
: undefined
}
onHistory={
edit.kind === 'edit'
? () => setHistoryKey(edit.record.businessKey)
: undefined
}
>
{detailQuery.data && edit.kind === 'create' && (
<SchemaDrivenForm
schema={detailQuery.data.schemaJson}
supportedLocales={detailQuery.data.supportedLocales}
defaultLocale={detailQuery.data.defaultLocale}
mode="create"
isPending={Boolean(activeMutation?.isPending)}
serverError={serverError}
// Prefill validFrom = now для consistent UX с edit mode.
// Без этого create form тоже показывает пустое «Действует с».
defaultValues={{ validFrom: nowIsoLocal() }}
onSubmit={handleSubmit}
onCancel={() => setEdit({ kind: 'closed' })}
formId="record-form"
layout="sections"
searchFilter={drawerSearch}
onlyFilled={drawerOnlyFilled}
onCounterChange={(visible, total) => setDrawerCount({ visible, total })}
onDirtyChange={setRecordDirtyCount}
/>
)}
{detailQuery.data && edit.kind === 'edit' && rawRecordQuery.isLoading && (
<LoadingBlock size="md" label={t('loading')} />
)}
{detailQuery.data && edit.kind === 'edit' && rawRecordQuery.error && (
<QueryErrorState
error={rawRecordQuery.error}
onRetry={() => rawRecordQuery.refetch()}
/>
)}
{detailQuery.data && edit.kind === 'edit' && rawRecordQuery.data && (
<SchemaDrivenForm
schema={detailQuery.data.schemaJson}
supportedLocales={detailQuery.data.supportedLocales}
defaultLocale={detailQuery.data.defaultLocale}
mode="edit"
isPending={Boolean(activeMutation?.isPending)}
serverError={serverError}
defaultValues={{
businessKey: rawRecordQuery.data.businessKey,
data: rawRecordQuery.data.data,
// Edit в bitemporal model = «создаём новую версию с этого момента,
// старая закрывается». Prefill nowIsoLocal() чтобы юзер видел
// конкретную дату («сейчас» как точка отсчёта), а не пустое поле.
// User feedback: "Действует с показывает ничего, а даты заданы" —
// empty field выглядит как баг. handleSubmit всё равно подставит
// свежий nowIsoLocal() если поле не тронуто (review #5 защита от
// stale значения если drawer долго открыт).
// validTo пустой = бессрочно (или до следующей правки).
validFrom: nowIsoLocal(),
validTo: '',
}}
onSubmit={handleSubmit}
onCancel={() => setEdit({ kind: 'closed' })}
formId="record-form"
layout="sections"
searchFilter={drawerSearch}
onlyFilled={drawerOnlyFilled}
onCounterChange={(visible, total) => setDrawerCount({ visible, total })}
onDirtyChange={setRecordDirtyCount}
/>
)}
</RecordDrawer>
<Modal
isOpen={edit.kind === 'close-confirm'}
onClose={() => {
setEdit({ kind: 'closed' })
setCloseReason('')
}}
title={t('dict.confirmClose.title')}
maxWidth="max-w-md"
>
{edit.kind === 'close-confirm' && (
<div className="space-y-4">
<p className="text-body text-ink">
{t('dict.confirmClose.body', { key: edit.record.businessKey })}
</p>
{approvalRequired && (
<Alert variant="info" title={t('dict.confirmClose.approvalTitle', {
defaultValue: 'Закрытие пройдёт через ревью',
})}>
{t('dict.confirmClose.approvalBody', {
defaultValue: 'Этот справочник требует maker-checker approval. Будет создан CLOSE-draft, запись закроется после approve от reviewer\'а.',
})}
</Alert>
)}
<TextInput
label={t('dict.confirmClose.reason')}
placeholder={t('dict.confirmClose.reasonPlaceholder')}
value={closeReason}
onChange={(e) => setCloseReason(e.target.value)}
/>
{serverErrorMessage(
approvalRequired ? submitDraftMut.error : closeMut.error,
) && (
<Alert variant="error">
{serverErrorMessage(
approvalRequired ? submitDraftMut.error : closeMut.error,
)}
</Alert>
)}
<div className="flex justify-end gap-2 pt-2 border-t border-line">
<Button
type="button"
variant="ghost"
disabled={approvalRequired ? submitDraftMut.isPending : closeMut.isPending}
onClick={() => setEdit({ kind: 'closed' })}
>
{t('form.cancel')}
</Button>
<Button
type="button"
variant="danger"
loading={approvalRequired ? submitDraftMut.isPending : closeMut.isPending}
onClick={handleClose}
>
{approvalRequired
? t('dict.action.closeAsDraft', {
defaultValue: 'Отправить на ревью',
})
: t('dict.action.close')}
</Button>
</div>
</div>
)}
</Modal>
{detailQuery.data && (
<DictionaryEditorDialog
open={schemaEditOpen}
mode={{ kind: 'edit', existing: detailQuery.data }}
onClose={() => setSchemaEditOpen(false)}
onSuccess={() => setSchemaEditOpen(false)}
/>
)}
{detailQuery.data && (
<CreateSchemaDraftModal
open={createDraftOpen}
onClose={() => setCreateDraftOpen(false)}
detail={detailQuery.data}
/>
)}
<RecordHistoryDrawer
open={Boolean(historyKey)}
onClose={() => setHistoryKey(undefined)}
dictionaryName={name}
businessKey={historyKey}
// Compare: opens the conflict diff modal сравнивает selected version
// данные с текущим server state. Re-use ConflictDiffModal — оно
// exactly показывает "что было vs что есть" diff.
onCompare={canMutate ? (versionData) => {
if (!historyKey) return
const fakePayload: CreateRecordRequest = {
businessKey: historyKey,
data: versionData as Record<string, unknown>,
validFrom: undefined,
validTo: undefined,
}
setConflict({ payload: fakePayload, businessKey: historyKey })
} : undefined}
// Revert: submit update mutation с old version's data как payload.
// Backend создаст новую active version с этими data — старая history
// сохраняется (bitemporal). 409 на conflict перехватываем как при
// обычном edit.
onRevert={canMutate ? (versionData, version) => {
if (!historyKey) return
const confirmed = window.confirm(
t('history.revertConfirm', { v: version, defaultValue: `Откатить к v${version}? Создастся новая активная версия.` }) as string
)
if (!confirmed) return
updateMut.mutate({
businessKey: historyKey,
payload: {
businessKey: historyKey,
data: versionData as Record<string, unknown>,
validFrom: undefined,
validTo: undefined,
},
idempotencyKey: recordIdempotencyKey,
}, {
onSuccess: () => {
setHistoryKey(undefined)
resetRecordIdempotencyKey()
},
})
} : undefined}
// Reopen: submit REOPEN draft. Same approval gate как CLOSE — пройдёт
// через DRAFT_SUBMIT → DRAFT_APPROVE. Admin self-approve работает
// если admin role есть. Backend применит applyApprovedReopen и
// создаст новую active версию с данными последней closed-версии.
onReopen={canMutate ? () => {
if (!historyKey) return
const confirmed = window.confirm(
t('history.reopenConfirm', { defaultValue: 'Открыть запись заново? Будет создан REOPEN-draft, требуется approve.' }) as string
)
if (!confirmed) return
submitDraftMut.mutate({
businessKey: historyKey,
operation: 'REOPEN',
comment: null,
}, {
onSuccess: () => {
setHistoryKey(undefined)
},
})
} : undefined}
/>
<CascadeConfirmDialog
open={Boolean(cascadeKey)}
onClose={() => setCascadeKey(undefined)}
onSuccess={() => {
setCascadeKey(undefined)
recordsResult.refetch()
}}
dictionaryName={name}
businessKey={cascadeKey}
/>
<AoiPickerDialog
isOpen={aoiOpen}
onClose={() => setAoiOpen(false)}
onApply={handleAoiApply}
initial={aoi?.geojson ?? null}
/>
{/* Conflict diff modal — opens on 409 generic conflict (optimistic lock).
* Diff: editor payload vs server current state (refetched). Three actions:
* - Отмена: close modal, keep editor (user retry/edit manually)
* - Загрузить серверное: discard local, open editor с server snapshot
* - Перезаписать: re-submit with current payload (will overwrite if no
* new conflict — backend has no force-overwrite endpoint поэтому
* просто retry; если опять 409 — modal снова откроется). */}
{conflict && (
<ConflictDiffModal
open
onClose={() => setConflict(null)}
localPayload={conflict.payload}
dictionaryName={name}
businessKey={conflict.businessKey}
onReloadServer={() => {
// Discard local edits, refetch records → пользователь увидит
// актуальное состояние. Закрываем editor; user может открыть
// запись ещё раз для редактирования с правильной базы.
setConflict(null)
setEdit({ kind: 'closed' })
resetRecordIdempotencyKey()
void recordsResult.refetch()
}}
onOverwrite={() => {
// Re-submit с current payload. Backend re-evaluates; если record
// изменился ещё раз → новый 409, modal откроется заново.
const payload = conflict.payload
const bk = conflict.businessKey
setConflict(null)
if (edit.kind === 'edit') {
updateMut.mutate(
{ businessKey: bk, payload, idempotencyKey: recordIdempotencyKey },
{
onSuccess: () => {
setEdit({ kind: 'closed' })
resetRecordIdempotencyKey()
},
onError: (err) => {
const status = (err as { response?: { status?: number } })
?.response?.status
if (status === 409) {
// Re-open diff modal с свежим server state.
setConflict({ payload, businessKey: bk })
}
},
},
)
}
}}
/>
)}
{/* Export modal — only mount when open. Same lazy pattern as Time-travel. */}
{exportOpen && detailQuery.data && (
<ExportModal
open
onClose={() => setExportOpen(false)}
detail={detailQuery.data}
totalCount={totalRecords}
filteredRecords={filteredRecords}
selection={selection}
onExportCsv={(keys) => bulkExportMut.mutate(keys)}
exportPending={bulkExportMut.isPending}
/>
)}
<Modal
isOpen={bulkOpen}
onClose={() => {
setBulkOpen(false)
setBulkResult(null)
setBulkReason('')
}}
title={t('dict.bulk.confirmTitle')}
maxWidth="max-w-lg"
>
<BulkCloseDialog
count={selection.size}
reason={bulkReason}
onReasonChange={setBulkReason}
onSubmit={handleBulkClose}
onClose={() => {
setBulkOpen(false)
setBulkResult(null)
setBulkReason('')
}}
isPending={approvalRequired ? bulkDraftRunning : bulkCloseMut.isPending}
serverError={serverErrorMessage(
approvalRequired ? submitDraftMut.error : bulkCloseMut.error,
)}
result={bulkResult}
approvalMode={approvalRequired}
/>
</Modal>
{/* Delete dict confirmation modal — admin-only. Cascade на
* records + drafts + schema_drafts через FK CASCADE (migration 0030).
* Backend проверяет incoming dependents через lineage и rejектит с
* 409 has_dependents если другой dict ссылается через x-references.
* UI показывает error text + предлагает force option. */}
<Modal
isOpen={deleteConfirmOpen}
onClose={() => {
if (deleteMut.isPending) return
setDeleteConfirmOpen(false)
setDeleteError(null)
}}
title={t('dict.delete.title', { defaultValue: 'Удалить справочник?' })}
maxWidth="max-w-lg"
>
<div className="space-y-4">
<p className="text-cell text-ink-2">
{t('dict.delete.warning', {
defaultValue:
'Будут удалены: ВСЕ записи справочника, drafts, schema-drafts, история версий. Эта операция необратима.',
})}
</p>
<div className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-cell">
<strong>{name}</strong>
{detailQuery.data?.displayName ? `${detailQuery.data.displayName}` : null}
</div>
{deleteError && (
<div className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-cell text-destructive whitespace-pre-line">
{deleteError}
</div>
)}
<div className="flex justify-end gap-2 pt-2">
<button
type="button"
className="h-9 px-4 rounded-md border border-line bg-surface text-ink-2 text-cell hover:bg-surface-2"
onClick={() => {
setDeleteConfirmOpen(false)
setDeleteError(null)
}}
disabled={deleteMut.isPending}
>
{t('common.cancel', { defaultValue: 'Отмена' })}
</button>
<button
type="button"
className="h-9 px-4 rounded-md bg-destructive text-destructive-foreground text-cell font-medium hover:bg-destructive/85 disabled:opacity-50 disabled:cursor-not-allowed"
onClick={() => {
setDeleteError(null)
deleteMut.mutate(
{ name },
{
onSuccess: () => {
setDeleteConfirmOpen(false)
navigate({ to: '/dictionaries' })
},
onError: (err) => {
const code = (err as { response?: { data?: { code?: string } } })?.response
?.data?.code
const msg = (err as { response?: { data?: { message?: string } } })?.response
?.data?.message
if (code === 'has_dependents') {
setDeleteError(
(msg ?? 'Есть зависимые справочники.') +
'\n\n' +
t('dict.delete.forceHint', {
defaultValue:
'Можно повторить с force=true — но зависимые справочники будут ссылаться на удалённое (FK validation сломается).',
}),
)
} else {
setDeleteError(msg ?? String(err))
}
},
},
)
}}
disabled={deleteMut.isPending}
>
{deleteMut.isPending
? t('dict.delete.pending', { defaultValue: 'Удаляю…' })
: t('dict.delete.confirm', { defaultValue: 'Удалить безвозвратно' })}
</button>
{deleteError?.includes('has_dependents') ||
(deleteError &&
deleteError.toLowerCase().includes('зависимы')) ? (
<button
type="button"
className="h-9 px-4 rounded-md border border-destructive/40 bg-surface text-destructive text-cell hover:bg-destructive/10 disabled:opacity-50 disabled:cursor-not-allowed"
onClick={() => {
setDeleteError(null)
deleteMut.mutate(
{ name, force: true },
{
onSuccess: () => {
setDeleteConfirmOpen(false)
navigate({ to: '/dictionaries' })
},
onError: (err) => {
const msg = (err as { response?: { data?: { message?: string } } })
?.response?.data?.message
setDeleteError(msg ?? String(err))
},
},
)
}}
disabled={deleteMut.isPending}
>
{t('dict.delete.force', { defaultValue: 'Удалить force (всё равно)' })}
</button>
) : null}
</div>
</div>
</Modal>
</div>
)
}
type BulkSelectionToolbarProps = {
count: number
totalVisible: number
onBulkClose: () => void
onClear: () => void
}
/**
* BulkSelectionToolbar — sticky bar появляется когда selection.size > 0.
*
* <p>Per user feedback: "Экспорт CSV в bulk bar лишний — повтор Экспорт
* кнопки в InfoPanel". Bulk export достижим через InfoPanel "↓ Экспорт..."
* → ExportModal с scope=selected (модал автоматически выбирает selected
* если selection непустой). Чтобы не дублировать, в bulk bar оставляем
* только destructive action — Закрыть выбранные.
*/
function BulkSelectionToolbar({
count,
totalVisible,
onBulkClose,
onClear,
}: BulkSelectionToolbarProps) {
const { t } = useTranslation()
return (
<div
className={[
'sticky top-0 z-10 -mx-1 flex items-center justify-between gap-3',
'bg-accent text-white rounded-md shadow-sm px-4 py-2',
].join(' ')}
>
<div className="flex items-center gap-3">
<span className="text-body font-medium tabular-nums">
{t('dict.bulk.selectedCount', { count, total: totalVisible })}
</span>
<button
type="button"
onClick={onClear}
className="inline-flex items-center gap-1 text-cell underline-offset-2 hover:underline opacity-80 hover:opacity-100"
>
<XIcon size={12} weight="bold" />
{t('dict.bulk.clearSelection')}
</button>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="danger"
leftIcon={<TrashIcon weight="bold" size={14} />}
onClick={onBulkClose}
>
{t('dict.bulk.closeAction')}
</Button>
</div>
</div>
)
}
type RecordsPaginationProps = {
page: number
totalPages: number
totalCount: number
pageSize: number
onPrev: () => void
onNext: () => void
}
function RecordsPagination({
page,
totalPages,
totalCount,
pageSize,
onPrev,
onNext,
}: RecordsPaginationProps) {
const { t } = useTranslation()
const from = page * pageSize + 1
const to = Math.min((page + 1) * pageSize, totalCount)
return (
<div className="flex items-center justify-between flex-wrap gap-3 px-2">
<span className="text-cell text-ink-2 tabular-nums">
{t('dict.page.range', { from, to, total: totalCount })}
</span>
<div className="flex items-center gap-3 text-body">
<Button
type="button"
variant="secondary"
leftIcon={<CaretLeftIcon weight="bold" size={14} />}
onClick={onPrev}
disabled={page <= 0}
>
{t('dict.page.prev')}
</Button>
<span className="tabular-nums text-cell text-ink-2">
{t('dict.page.of', { cur: page + 1, total: totalPages })}
</span>
<Button
type="button"
variant="secondary"
rightIcon={<CaretRightIcon weight="bold" size={14} />}
onClick={onNext}
disabled={page + 1 >= totalPages}
>
{t('dict.page.next')}
</Button>
</div>
</div>
)
}
type BulkCloseDialogProps = {
count: number
reason: string
onReasonChange: (s: string) => void
onSubmit: () => void
onClose: () => void
isPending: boolean
serverError: string | null
result: BulkCloseResponse | null
/** Approval Workflow v2: на approval-required dict bulk-close шлёт N drafts
* CLOSE-операции вместо прямого закрытия. UI меняет copy + summary semantics. */
approvalMode?: boolean
}
function BulkCloseDialog({
count,
reason,
onReasonChange,
onSubmit,
onClose,
isPending,
serverError,
result,
approvalMode = false,
}: BulkCloseDialogProps) {
const { t } = useTranslation()
// После успеха — показываем сводку; вход на confirm форму прячем
if (result) {
return (
<div className="space-y-4">
<div className="space-y-2">
{result.closed.length > 0 && (
<div className="flex items-center gap-2 text-body text-soyuz-green">
<CheckCircleIcon size={18} weight="fill" />
<span>{t('dict.bulk.resultClosed', { count: result.closed.length })}</span>
</div>
)}
{result.skipped.length > 0 && (
<div className="flex items-center gap-2 text-body text-saturn">
<WarningIcon size={18} weight="fill" />
<span>{t('dict.bulk.resultSkipped', { count: result.skipped.length })}</span>
</div>
)}
{result.errors.length > 0 && (
<div className="flex items-center gap-2 text-body text-mars">
<XCircleIcon size={18} weight="fill" />
<span>{t('dict.bulk.resultErrors', { count: result.errors.length })}</span>
</div>
)}
</div>
{(result.skipped.length > 0 || result.errors.length > 0) && (
<div className="border border-line rounded p-3 max-h-48 overflow-auto bg-line/30">
<ul className="text-mono space-y-1">
{result.skipped.map((s) => (
<li key={`s-${s.businessKey}`} className="text-saturn">
· {s.businessKey} {s.reason}
</li>
))}
{result.errors.map((e) => (
<li key={`e-${e.businessKey}`} className="text-mars">
! {e.businessKey} {e.reason}
</li>
))}
</ul>
</div>
)}
<div className="flex justify-end pt-2 border-t border-line">
<Button type="button" variant="primary" onClick={onClose}>
{t('dict.bulk.dismiss')}
</Button>
</div>
</div>
)
}
return (
<div className="space-y-4">
<p className="text-body text-ink">{t('dict.bulk.confirmBody', { count })}</p>
{approvalMode && (
<Alert
variant="info"
title={t('dict.bulk.approvalTitle', {
defaultValue: 'Закрытие пройдёт через ревью',
})}
>
{t('dict.bulk.approvalBody', {
count,
defaultValue: `${count} CLOSE-draft(ов) будут отправлены на ревью. Записи закроются после approve.`,
})}
</Alert>
)}
<TextInput
label={t('dict.confirmClose.reason')}
placeholder={t('dict.confirmClose.reasonPlaceholder')}
value={reason}
onChange={(e) => onReasonChange(e.target.value)}
/>
{serverError && <Alert variant="error">{serverError}</Alert>}
<div className="flex justify-end gap-2 pt-2 border-t border-line">
<Button
type="button"
variant="ghost"
disabled={isPending}
onClick={onClose}
>
{t('form.cancel')}
</Button>
<Button
type="button"
variant="danger"
loading={isPending}
onClick={onSubmit}
leftIcon={<TrashIcon weight="bold" size={14} />}
>
{approvalMode
? t('dict.bulk.confirmActionAsDraft', {
count,
defaultValue: `Отправить ${count} CLOSE-draft(ов) на ревью`,
})
: t('dict.bulk.confirmAction', { count })}
</Button>
</div>
</div>
)
}
/**
* Bootstrap initial AOI из URL bbox param. Возвращает bbox-AOI с
* прямоугольным GeoJSON для preview в picker'е. Невалидные значения
* уже отфильтрованы validateSearch — но dup'аем guard на парсинге.
*/
/** snake_case → 'Snake Case' для column header'ов. */
/** Count unique outgoing FK targets across schema properties (x-references). */
const extractOutgoingFkCount = (schema: import('@/api/client').JsonSchema | undefined): number => {
if (!schema?.properties) return 0
const targets = new Set<string>()
for (const prop of Object.values(schema.properties)) {
const ref = prop['x-references']
if (typeof ref === 'string' && ref.length > 0) {
const [target] = ref.split('.')
if (target) targets.add(target)
}
}
return targets.size
}
const humanize = (key: string): string =>
key.replace(/[_-]+/g, ' ').replace(/\b\w/g, (ch) => ch.toUpperCase())
const bboxToAoi = (bboxCsv: string | undefined): AoiResult | null => {
if (!bboxCsv) return null
const parts = bboxCsv.split(',').map(Number)
if (parts.length !== 4 || parts.some((n) => Number.isNaN(n))) return null
const [w, s, e, n] = parts
return {
kind: 'bbox',
bboxCsv,
geojson: {
type: 'Polygon',
coordinates: [
[
[w, s],
[e, s],
[e, n],
[w, n],
[w, s],
],
],
},
}
}
const serverErrorMessage = (err: unknown): string | null => {
if (!err) return null
if (axios.isAxiosError(err)) {
const data = err.response?.data as { message?: string; error?: string } | undefined
if (err.response?.status === 422) return data?.message ?? 'Validation failed'
if (err.response?.status === 409) return data?.message ?? 'Conflict — request already in progress'
return data?.message ?? data?.error ?? err.message
}
return err instanceof Error ? err.message : 'Unknown error'
}
// ===== Editor Tabs (Stage 3.4) =====
type EditorTab = 'records' | 'relations' | 'fields' | 'json' | 'events' | 'history'
function EditorTabBar({
active,
onChange,
counts,
actions,
}: {
active: EditorTab
onChange: (tab: EditorTab) => void
/** Optional per-tab counters — render как inline mono badge per redesign. */
counts?: Partial<Record<EditorTab, number | undefined>>
/** Right-aligned action buttons (Schema / +Запись) per redesign prototype. */
actions?: React.ReactNode
}) {
const { t } = useTranslation()
const tabs: { id: EditorTab; label: string }[] = [
{ id: 'records', label: t('editor.tab.records', { defaultValue: 'Записи' }) },
{ id: 'relations', label: t('editor.tab.relations', { defaultValue: 'Связи' }) },
{ id: 'fields', label: t('editor.tab.fields', { defaultValue: 'Поля' }) },
{ id: 'json', label: t('editor.tab.json', { defaultValue: 'JSON' }) },
{ id: 'events', label: t('editor.tab.events', { defaultValue: 'События' }) },
{ id: 'history', label: t('editor.tab.history', { defaultValue: 'История' }) },
]
// QA bug #3: на 1075px tabs (6 шт.) + actions (3 кнопки) переполняли flex
// row → "+ Создать" обрезалось до "+ Соз". Решение: actions выносим в
// отдельный shrink-0 контейнер, tabs кладём в overflow-x-auto wrapper
// с min-w-0 → tabs скроллятся горизонтально если cramped, actions всегда
// видны полностью. whitespace-nowrap на tab buttons чтобы текст не
// переносился в кружок счётчика.
return (
<div className="flex items-center border-b border-line w-full">
<div className="flex items-center gap-0 overflow-x-auto flex-1 min-w-0 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{tabs.map((tab) => {
const isActive = active === tab.id
const count = counts?.[tab.id]
return (
<button
key={tab.id}
type="button"
onClick={() => onChange(tab.id)}
aria-pressed={isActive}
className={[
'px-3.5 h-10 text-body transition-colors -mb-px border-b-2 inline-flex items-center gap-1.5 shrink-0 whitespace-nowrap',
isActive
? 'border-accent text-ink font-semibold'
: 'border-transparent text-ink-2 hover:text-ink',
].join(' ')}
>
{tab.label}
{typeof count === 'number' && count > 0 && (
<span className="text-mono text-mute">{count}</span>
)}
</button>
)
})}
</div>
{actions && (
<div className="flex items-center gap-2 pl-2 pr-2 shrink-0">{actions}</div>
)}
</div>
)
}
/** Fields tab — schema properties summary. */
function FieldsTabContent({ detail }: { detail: { schemaJson: import('@/api/client').JsonSchema } }) {
const props = detail.schemaJson.properties ?? {}
const required = new Set(detail.schemaJson.required ?? [])
const entries = Object.entries(props)
if (entries.length === 0) {
return (
<div className="text-body text-mute py-8 text-center">
Schema без properties.
</div>
)
}
// Render type per prototype: для FK → `fk → target_dict`. Для array → `string[]`.
// Иначе native JSON-schema type.
const renderType = (prop: import('@/api/client').JsonSchema): React.ReactNode => {
const ref = prop['x-references']
if (typeof ref === 'string' && ref.length > 0) {
const [target] = ref.split('.')
return (
<span className="text-mono text-ink-2">
fk <span className="text-mute"></span>{' '}
<span className="text-accent">{target}</span>
</span>
)
}
if (Array.isArray(prop.enum) && prop.enum.length > 0) {
return <span className="text-mono text-ink-2">enum</span>
}
if (prop.type === 'array' && prop.items) {
const itemT = typeof prop.items === 'object' ? prop.items.type ?? '—' : '—'
return <span className="text-mono text-ink-2">{itemT}[]</span>
}
if (prop.format === 'date' || prop.format === 'date-time') {
return <span className="text-mono text-ink-2">{prop.format}</span>
}
return <span className="text-mono text-ink-2">{prop.type ?? '—'}</span>
}
const checkOrDash = (v: unknown) =>
v ? (
<span className="text-green" aria-label="yes">
</span>
) : (
<span className="text-mute/50" aria-hidden>
</span>
)
return (
<div className="rounded-lg border border-line bg-surface overflow-hidden">
<table className="w-full text-body">
<thead className="text-cap font-display text-mute tracking-[0.14em] uppercase">
<tr>
<th className="text-left px-3 py-2.5 w-12 font-mono text-mute/70 normal-case">#</th>
<th className="text-left px-3 py-2.5">{`Поле`}</th>
<th className="text-left px-3 py-2.5 w-[160px]">{`Тип`}</th>
<th className="text-center px-3 py-2.5 w-20">Required</th>
<th className="text-center px-3 py-2.5 w-20">Unique</th>
<th className="text-center px-3 py-2.5 w-20">I18n</th>
<th className="text-center px-3 py-2.5 w-20">Index</th>
<th className="text-left px-3 py-2.5">{`Описание`}</th>
</tr>
</thead>
<tbody>
{entries.map(([key, prop], idx) => (
<tr
key={key}
className="border-t border-line-2 bg-surface hover:bg-surface-2 transition-colors"
>
<td className="px-3 py-2 text-mono text-mute tabular-nums">
{String(idx + 1).padStart(2, '0')}
</td>
<td className="px-3 py-2 font-mono font-semibold text-accent">
{key}
</td>
<td className="px-3 py-2">{renderType(prop)}</td>
<td className="px-3 py-2 text-center">{checkOrDash(required.has(key))}</td>
<td className="px-3 py-2 text-center">{checkOrDash(prop['x-unique'])}</td>
<td className="px-3 py-2 text-center">{checkOrDash(prop['x-localized'])}</td>
{/* INDEX: backend ещё не выводит явный флаг. Используем эвристику:
* x-unique → автоматически индекс; x-references → FK index; иначе —. */}
<td className="px-3 py-2 text-center">
{checkOrDash(prop['x-unique'] || Boolean(prop['x-references']))}
</td>
<td className="px-3 py-2 text-ink-2 text-cell max-w-[320px] truncate">
{prop.description ?? ''}
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
/**
* JSON tab — raw schema JSON с syntax highlight + copy button.
* Stage 3.4 polish: handoff requested Monaco editor. Monaco = ~2MB bundle
* cost; для read-only display это overkill. Используем regex-based highlight
* (keys/strings/numbers/booleans), это даёт 90% UX benefit at 0 bundle cost.
* Если потребуется EDIT (Stage 3.x) — добавим Monaco lazy через React.lazy.
*/
function JsonTabContent({ detail }: { detail: { schemaJson: import('@/api/client').JsonSchema } }) {
const { t } = useTranslation()
const [copied, setCopied] = useState(false)
const json = useMemo(() => JSON.stringify(detail.schemaJson, null, 2), [detail.schemaJson])
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(json)
setCopied(true)
setTimeout(() => setCopied(false), 1500)
} catch {
// clipboard API недоступен (insecure context) — silently ignore.
}
}
return (
<div className="relative">
<button
type="button"
onClick={handleCopy}
// Copy button — dark-glass overlay поверх code block для contrast
// с тёмным фоном (см. pre).
className="absolute top-2 right-2 z-10 px-2.5 py-1 rounded-sm border border-[#3d3863] bg-[#1c1840]/85 backdrop-blur text-cap text-[#c5bff0] hover:text-[#f0eaff] hover:border-[#ff9a64] transition-colors"
>
{copied ? t('json.copied', { defaultValue: 'Скопировано' }) : t('json.copy', { defaultValue: 'Копировать' })}
</button>
{/* Code block — dark theme (space palette из graph), убираем cream
* bg-surface-2 который user feedback'нул как «желтый кремовый».
* Highlight token colors (text-accent/green/pink/warn) работают на
* тёмном фоне OK благодаря accessible contrast. */}
<pre className="rounded-lg border border-[#3d3863] bg-[#0c0a23] p-4 overflow-x-auto text-cell font-mono text-[#f0eaff] whitespace-pre">
<code dangerouslySetInnerHTML={{ __html: highlightJson(json) }} />
</pre>
</div>
)
}
/**
* Lightweight JSON syntax highlight via regex. Returns HTML с inline
* span'ами окрашенными tokens — works для display-only (Monaco не нужен).
*
* <p>Каждый escape перед взаимодействием: source already JSON.stringify'd
* (safe), но regex применяется к HTML так что & < > > сначала escape'аются.
*/
/**
* Error state для route когда `useDictionaryDetail` returns 404 либо другой
* error. Special-case: если dict в /dictionaries/deleted (Корзина) — show
* inline restore card вместо generic error. 7-day retention window:
* admin может restore в этот период, дальше DictionaryPurgeJob physically
* removes (recovery только из DB backup).
*
* <p>Non-admin / dict не в Корзине → стандартный QueryErrorState.
*/
function DictionaryErrorOrSoftDeletedCard({
name,
error,
isAdmin,
onRetry,
}: {
name: string
error: unknown
isAdmin: boolean
onRetry: () => void
}) {
const { t } = useTranslation()
const navigate = useNavigate({ from: Route.fullPath })
const restoreMut = useRestoreDictionary()
// Дёргаем Корзину только если admin (non-admin получает 403 от backend).
const deletedQ = useDictionariesDeleted(isAdmin)
const status = (error as { response?: { status?: number } })?.response?.status
const isNotFound = status === 404
const softDeleted = deletedQ.data?.find((d) => d.name === name)
if (!isNotFound || !softDeleted) {
return <QueryErrorState error={error} onRetry={onRetry} />
}
// Сколько осталось до auto-purge (7-day retention).
const deletedAt =
(softDeleted as unknown as { deletedAt?: string; updatedAt?: string }).deletedAt
?? softDeleted.updatedAt
const deletedDate = deletedAt ? new Date(deletedAt) : null
const purgeDate = deletedDate ? new Date(deletedDate.getTime() + 7 * 24 * 60 * 60 * 1000) : null
const daysLeft = purgeDate
? Math.ceil((purgeDate.getTime() - Date.now()) / (24 * 60 * 60 * 1000))
: null
return (
<div className="max-w-2xl mx-auto mt-12">
<div className="rounded-md border border-destructive/30 bg-destructive/10 px-5 py-4 space-y-4">
<div className="flex items-start gap-3">
<TrashIcon size={24} weight="regular" className="text-destructive shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<h2 className="text-title-md text-ink mb-1">
{t('dict.deleted.title', { defaultValue: 'Справочник удалён' })}
</h2>
<p className="text-cell text-ink-2 mb-1">
<strong>{softDeleted.name}</strong>
{softDeleted.displayName ? `${softDeleted.displayName}` : null}
</p>
<p className="text-cell text-ink-2">
{daysLeft !== null && daysLeft > 0
? t('dict.deleted.retentionLeft', {
defaultValue:
'Восстановить можно в течение {{days}} дн. — после этого записи будут безвозвратно удалены.',
days: daysLeft,
})
: t('dict.deleted.retentionExpiringSoon', {
defaultValue: 'Срок восстановления истекает — purge cron сработает в ближайшие часы.',
})}
</p>
</div>
</div>
{isAdmin ? (
<div className="flex justify-end gap-2 pt-2 border-t border-destructive/20">
<button
type="button"
className="h-9 px-4 rounded-md border border-line bg-surface text-ink-2 text-cell hover:bg-surface-2"
onClick={() => navigate({ to: '/dictionaries' })}
>
{t('dict.deleted.backToList', { defaultValue: 'К списку справочников' })}
</button>
<button
type="button"
className="h-9 px-4 rounded-md bg-accent text-on-accent text-cell font-medium hover:bg-accent/85 disabled:opacity-50 disabled:cursor-not-allowed"
onClick={() => {
restoreMut.mutate(name, {
onSuccess: () => {
// После restore — refetch detail + navigate back to dict.
onRetry()
},
})
}}
disabled={restoreMut.isPending}
>
{restoreMut.isPending
? t('dict.deleted.restorePending', { defaultValue: 'Восстанавливаю…' })
: t('dict.deleted.restore', { defaultValue: 'Восстановить справочник' })}
</button>
</div>
) : (
<p className="text-cell text-mute pt-2 border-t border-destructive/20">
{t('dict.deleted.adminOnly', {
defaultValue: 'Восстановление справочника доступно только администратору.',
})}
</p>
)}
</div>
</div>
)
}
function highlightJson(json: string): string {
const escape = (s: string) =>
s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
return escape(json).replace(
/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(\.\d+)?([eE][+-]?\d+)?)/g,
(match) => {
let cls = 'text-warn'
if (/^"/.test(match)) {
cls = /:$/.test(match) ? 'text-accent' : 'text-green'
} else if (/true|false/.test(match)) {
cls = 'text-pink'
} else if (/null/.test(match)) {
cls = 'text-mute italic'
}
return `<span class="${cls}">${match}</span>`
},
)
}
/**
* Events tab — embedded last 20 audit entries filtered by dictName.
* Stage 3.4 polish: handoff требовал inline list вместо redirect к /audit.
*
* <p>Не показывает payloadBefore/After (expand-detail) — для полного diff
* остался deep-link к /audit?dict=name внизу. Здесь — таймстамп + action +
* businessKey + author, плотно в 4-col grid.
*/
function EventsTabContent({ dictName }: { dictName: string }) {
const { t } = useTranslation()
const auditQ = useAudit({ dictionaryName: dictName, size: 50 })
const { data, isLoading, error } = auditQ
// Filter chips per prototype: все / edit / publish / review / webhook / export.
// Backend audit actions: CREATE / UPDATE / CLOSE / EXPORT etc. — map to display
// groups for chip filter.
const [filter, setFilter] = useState<'all' | 'edit' | 'publish' | 'review' | 'webhook' | 'export'>('all')
if (isLoading) return <LoadingBlock size="md" label={t('loading')} />
if (error)
return <QueryErrorState error={error} onRetry={() => auditQ.refetch()} />
const rows = data?.content ?? []
if (rows.length === 0)
return (
<EmptyState
title={t('events.empty.title', { defaultValue: 'Событий пока нет' })}
description={t('events.empty.description', {
defaultValue: 'Любая правка записей справочника появится здесь.',
})}
/>
)
/** Group audit action в chip filter category. */
const eventCategory = (action: string): 'edit' | 'publish' | 'review' | 'webhook' | 'export' => {
const a = action.toUpperCase()
if (a === 'CREATE' || a === 'UPDATE' || a === 'CLOSE') return 'edit'
if (a === 'EXPORT') return 'export'
if (a === 'PUBLISH') return 'publish'
if (a === 'REVIEW' || a === 'APPROVE' || a === 'DRAFT') return 'review'
if (a === 'WEBHOOK') return 'webhook'
return 'edit'
}
/** Counts per category для chip labels. */
const counts = {
all: rows.length,
edit: rows.filter((r) => eventCategory(r.action) === 'edit').length,
publish: rows.filter((r) => eventCategory(r.action) === 'publish').length,
review: rows.filter((r) => eventCategory(r.action) === 'review').length,
webhook: rows.filter((r) => eventCategory(r.action) === 'webhook').length,
export: rows.filter((r) => eventCategory(r.action) === 'export').length,
}
const visible = filter === 'all' ? rows : rows.filter((r) => eventCategory(r.action) === filter)
// Color chip per event type — соответствует prototype.
const typeChipClass = (cat: 'edit' | 'publish' | 'review' | 'webhook' | 'export'): string => {
switch (cat) {
case 'webhook':
return 'bg-accent-bg text-accent'
case 'publish':
return 'bg-green-bg text-green'
case 'review':
return 'bg-warn-bg text-warn'
case 'export':
return 'bg-surface-2 text-ink-2'
case 'edit':
default:
return 'bg-accent-bg/50 text-accent'
}
}
const FILTER_CHIPS: { id: typeof filter; label: string; count: number }[] = [
{ id: 'all', label: t('events.filter.all', { defaultValue: 'все' }), count: counts.all },
{ id: 'webhook', label: 'webhooks', count: counts.webhook },
{ id: 'publish', label: 'publish', count: counts.publish },
{ id: 'review', label: 'review', count: counts.review },
{ id: 'edit', label: 'edit', count: counts.edit },
{ id: 'export', label: 'export', count: counts.export },
]
return (
<div className="space-y-3">
{/* Filter chips per prototype line 07-editor-events */}
<div className="flex items-center gap-1.5 flex-wrap">
{FILTER_CHIPS.filter((c) => c.id === 'all' || c.count > 0).map((c) => {
const active = filter === c.id
return (
<button
key={c.id}
type="button"
onClick={() => setFilter(c.id)}
aria-pressed={active}
className={cn(
'h-7 px-3 rounded-md text-body transition-colors inline-flex items-center gap-1.5',
active
? 'bg-navy text-on-accent font-semibold'
: 'border border-line bg-surface text-ink-2 hover:bg-surface-2',
)}
>
{c.label}
<span className={cn('text-mono', active ? 'opacity-90' : 'text-mute')}>
{c.count}
</span>
</button>
)
})}
<Link
to="/audit"
search={{ dict: dictName }}
className="ml-auto h-7 px-3 rounded-md border border-line bg-surface text-ink-2 hover:bg-surface-2 text-body inline-flex items-center transition-colors"
>
{t('events.viewAll.short', { defaultValue: 'Полный лог →' })}
</Link>
</div>
<div className="rounded-lg border border-line bg-surface overflow-hidden">
<table className="w-full">
<thead>
<tr className="text-cap text-mute tracking-[0.14em] uppercase font-display border-b border-line-2">
<th className="text-left px-3 py-2 w-[140px]">
{t('audit.col.time', { defaultValue: 'Время' })}
</th>
<th className="text-left px-3 py-2 w-24">
{t('audit.col.action', { defaultValue: 'Тип' })}
</th>
<th className="text-left px-3 py-2 w-20">
{t('audit.col.status', { defaultValue: 'Статус' })}
</th>
<th className="text-left px-3 py-2 w-[180px]">
{t('audit.col.user', { defaultValue: 'Куда · Кто' })}
</th>
<th className="text-left px-3 py-2">
{t('audit.col.businessKey', { defaultValue: 'Сообщение' })}
</th>
</tr>
</thead>
<tbody>
{visible.map((r) => {
const time = new Date(r.eventTime)
const cat = eventCategory(r.action)
// Backend audit currently не выставляет explicit status field;
// treat CREATE/UPDATE/CLOSE/EXPORT как 'ok'. Future: parse error
// logs для 'fail' state.
const status: 'ok' | 'fail' = 'ok'
return (
<tr
key={`${r.id}`}
className="border-t border-line-2 bg-surface hover:bg-surface-2 transition-colors"
>
<td className="text-mono text-cell tabular-nums px-3 py-2 text-ink-2">
{time.toLocaleString(undefined, {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})}
</td>
<td className="px-3 py-2">
<span
className={cn(
'inline-flex items-center px-2 py-0.5 rounded-sm text-cap tracking-wider font-semibold uppercase',
typeChipClass(cat),
)}
>
{cat}
</span>
</td>
<td className="px-3 py-2">
<span className="inline-flex items-center gap-1.5 text-cell text-ink-2">
<span
className={cn(
'size-1.5 rounded-full',
status === 'ok' ? 'bg-green' : 'bg-pink',
)}
aria-hidden
/>
{status}
</span>
</td>
<td className="px-3 py-2 text-cell text-ink-2">
{r.userId ? <UserCell uuid={r.userId} /> : <span className="text-mono text-mute">anonymous</span>}
</td>
<td className="px-3 py-2 text-cell text-ink-2 max-w-md truncate">
{r.businessKey ? (
<span>
<span className="text-mono text-accent">{r.businessKey}</span>
{' · '}
<span className="text-mute">{r.action}</span>
</span>
) : (
<span className="text-mute">{r.action}</span>
)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
)
}
/**
* History tab — schema changelog timeline. Версионные events (создание
* словаря, schema update, draft publish) рендерятся с version badge;
* promotional draft events (review/approve/reject/changes_requested/
* withdraw) — без version, neutral tone. Каждый entry имеет «Сравнить»
* который открывает {@link ChangelogDiffModal} c full before/after.
*
* <p>Backend: GET /api/v1/dictionaries/{name}/changelog → ChangelogResponse.
* Diff endpoint lazy fetch'ится в modal.
*
* <p>Empty state — для свежесозданных словарей без update'ов.
*/
function HistoryTabContent({ detail }: { detail?: import('@/api/client').DictionaryDetail }) {
const { t } = useTranslation()
const [diffEntryId, setDiffEntryId] = useState<number | undefined>(undefined)
const changelogQ = useChangelog(detail?.name)
const { data, isLoading, error } = changelogQ
if (!detail) return null
return (
<div className="space-y-3">
{isLoading && <LoadingBlock size="md" label={t('loading')} />}
{error && (
<QueryErrorState error={error} onRetry={() => changelogQ.refetch()} />
)}
{data && data.entries.length === 0 && (
<EmptyState
title={t('changelog.empty.title', {
defaultValue: 'История пуста',
})}
description={t('changelog.empty.description', {
defaultValue: 'Изменения схемы и события workflow появятся здесь.',
})}
/>
)}
{data && data.entries.length > 0 && (
<ol className="relative border-l-2 border-line pl-5 space-y-3">
{data.entries.map((entry) => (
<ChangelogTimelineItem
key={entry.id}
entry={entry}
onOpenDiff={() => setDiffEntryId(entry.id)}
/>
))}
</ol>
)}
<ChangelogDiffModal
open={diffEntryId !== undefined}
onClose={() => setDiffEntryId(undefined)}
dictionaryName={detail.name}
entryId={diffEntryId}
/>
</div>
)
}
function ChangelogTimelineItem({
entry,
onOpenDiff,
}: {
entry: import('@/api/client').ChangelogEntry
onOpenDiff: () => void
}) {
const { t } = useTranslation()
const KindIcon = kindIcon(entry.kind)
const occurredAt = new Date(entry.publishedAt)
const showVersion = isVersioningKind(entry.kind) && entry.version
const added = entry.diff.added.length
const changed = entry.diff.changed.length
const removed = entry.diff.removed.length
return (
<li className="relative">
{/* Timeline dot marker — kind-coloured */}
<span
className={cn(
'absolute -left-[27px] top-1.5 size-3 rounded-full border-2 bg-surface',
entry.isCurrent ? 'border-accent' : 'border-line-2',
)}
aria-hidden
/>
<div className="rounded-lg border border-line bg-surface p-3">
<div className="flex items-start gap-3">
<KindIcon weight="duotone" size={20} className="text-mute shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<div className="flex items-baseline gap-2 flex-wrap">
{showVersion && (
<span className="text-mono text-title-sm text-ink font-semibold tabular-nums">
v{entry.version}
</span>
)}
<Badge variant={kindBadgeVariant(entry.kind)}>
{kindLabel(entry.kind, t)}
</Badge>
{entry.isCurrent && (
<Badge variant="primary">
{t('history.current', { defaultValue: 'текущая' })}
</Badge>
)}
<span className="text-mono text-cell text-mute tabular-nums ml-auto">
{added > 0 && <span className="text-aurora">+{added}</span>}
{added > 0 && (changed > 0 || removed > 0) && ' '}
{changed > 0 && <span className="text-warn">~{changed}</span>}
{changed > 0 && removed > 0 && ' '}
{removed > 0 && <span className="text-danger">{removed}</span>}
</span>
</div>
{entry.headers && (
<div className="text-body text-ink mt-1 truncate">{entry.headers}</div>
)}
<div className="flex items-center gap-3 mt-1 text-cell text-mute">
<span className="tabular-nums">{occurredAt.toLocaleString()}</span>
<UserCell uuid={entry.publishedBy.sub} />
{entry.recordsAffected > 0 && (
<span>
{t('changelog.recordsAffected', {
count: entry.recordsAffected,
defaultValue: `${entry.recordsAffected} запис.`,
})}
</span>
)}
</div>
<div className="mt-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={onOpenDiff}
>
{t('changelog.viewDiff', { defaultValue: 'Сравнить' })}
</Button>
</div>
</div>
</div>
</div>
</li>
)
}
/**
* RecordColumnFilters — auto-derive filterable columns из schema +
* records data, render as compact select dropdowns "status ▾", "operator ▾".
*
* <p>Filterable columns детектятся по двум сигналам:
* <ol>
* <li>schema property имеет `enum` array → use enum values as options</li>
* <li>иначе walk records, собирай уникальные значения для top non-i18n
* string properties (status, operator, type). Если cardinality
* (unique count) ≤ 12 — это reasonable filter; иначе skip (нужен search).</li>
* </ol>
*
* <p>Replaces прошлый scope filter chips per user feedback ("фильтр в
* справочнике по статусу скорее нужен чем по scope").
*/
function RecordColumnFilters({
schema,
records,
value,
onChange,
}: {
schema?: import('@/api/client').JsonSchema
records: FlattenedRecord[]
value: Record<string, string>
onChange: (v: Record<string, string>) => 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<string>()
for (const r of records) {
const v = (r.data as Record<string, unknown>)[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 (
<div className="flex items-center gap-2 flex-wrap">
{filters.map((f) => (
<select
key={f.field}
value={value[f.field] ?? ''}
onChange={(e) =>
onChange({ ...value, [f.field]: e.target.value })
}
className="h-8 px-2 rounded-md border border-line bg-surface text-cell text-ink-2 hover:bg-surface-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent cursor-pointer"
aria-label={`Filter by ${f.label}`}
>
<option value="">{f.label} </option>
{f.options.map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</select>
))}
</div>
)
}