Files
mdm-ordinis/ordinis-admin-ui/src/routes/dictionaries.$name.tsx
T
Zimin A.N. 3e4d78906d feat(records): accent businessKey + remove redundant Export CSV from bulk bar
Per user feedback:
- "поле Бизнес-ключ выделен цветом" — businessKey column TableCell теперь
  `text-mono text-accent` (было plain text-mono). Matches prototype 03 где
  RES-P-1, KAN-V-3, SNT-1A показаны в accent orange — primary identifier
  для каждой row.
- "Экспорт CSV в bulk bar лишний как повтор Экспорт кнопки в InfoPanel" —
  убрал Export button из BulkSelectionToolbar. ExportModal pre-picks
  scope=selected когда selection.size > 0, поэтому workflow:
  select rows → click "↓ Экспорт..." в InfoPanel → modal с pre-selected
  scope. Бэк endpoint тот же, UX cleaner — один путь к export'у.
- Removed handleBulkExport callback + DownloadSimpleIcon import (теперь
  unused).
2026-05-11 20:14:43 +03:00

2016 lines
79 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,
EmptyState,
IconButton,
LoadingBlock,
Modal,
Panel,
SearchInput,
Table,
TableBody,
TableCell,
TableHead,
TableHeaderCell,
TableRow,
TextInput,
} from '@/ui'
import { PlusIcon, PencilSimpleIcon, XCircleIcon, GearIcon, ClockCounterClockwiseIcon, TrashIcon, CheckCircleIcon, WarningIcon, XIcon, CaretLeftIcon, CaretRightIcon } from '@phosphor-icons/react'
import {
useAudit,
useDictionaryDetail,
useDictPendingDrafts,
useRecordRaw,
useRecords,
type RecordsFilter,
} from '@/api/queries'
import { useBulkCloseRecords, useBulkExportRecords, useCreateRecord, useUpdateRecord, useCloseRecord, useFormIdempotencyKey } from '@/api/mutations'
import { useCanMutate } from '@/auth/useCanMutate'
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 { nowIsoLocal } from '@/lib/dates'
import { recordDisplayName } from '@/lib/locales'
import { SCOPE_BORDER_TOP, SCOPE_DOT, SCOPE_ORDER } from '@/lib/scope-style'
/**
* 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()
// 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)
/**
* 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])
const createMut = useCreateRecord(name)
const updateMut = useUpdateRecord(name)
// 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)
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 [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,
})
}
const toggleScope = (scope: DataScope) => {
const next = new Set(scopeFilter)
if (next.has(scope)) next.delete(scope)
else next.add(scope)
void navigate({
search: (prev) => ({
...prev,
scopes: next.size > 0 ? Array.from(next).join(',') : undefined,
}),
replace: true,
})
}
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 = () => {
void navigate({
search: (prev) => ({ ...prev, q: undefined, scopes: undefined }),
replace: true,
})
}
// Уникальные validFrom timestamps для timeline marks. Используем raw
// recordsResult (не filtered) чтобы видеть все версии независимо от scope/q
// фильтров.
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)
}
return Array.from(set).sort((a, b) => a - b)
}, [recordsResult.data])
const filteredRecords = useMemo(() => {
const raw = recordsResult.data ?? []
if (raw.length === 0) return raw
const q = search.trim().toLowerCase()
const scopes = scopeFilter
return raw.filter((r) => {
if (scopes.size > 0 && !scopes.has(r.dataScope)) 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])
const filtersActive = search.length > 0 || scopeFilter.size > 0
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])
const visibleRecords = useMemo(
() => filteredRecords.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE),
[filteredRecords, 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 handleBulkClose = () => {
if (selection.size === 0) 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: enum + reference поля — самые info-dense
// не-локализованные scalar'ы. Берём первые 2 чтобы не перегрузить
// строку (с businessKey + name + scope + extras + дата + actions = 6
// колонок, comfortably fits на 1280px). Skip name/code/businessKey
// (уже показаны), всё нескалярное (object/array/localized).
const extraColumns = useMemo<{ key: string; label: string; isFk: boolean }[]>(() => {
const props = detailQuery.data?.schemaJson?.properties ?? {}
const interesting: { key: string; label: string; isFk: boolean }[] = []
for (const [key, prop] of Object.entries(props)) {
if (interesting.length >= 2) break
if (key === 'name' || key === 'code' || key === 'businessKey') continue
const isLocalized = Boolean(prop['x-localized'])
if (isLocalized) continue
const isEnum = Array.isArray(prop.enum) && prop.enum.length > 0
const isFk = typeof prop['x-references'] === 'string' && Boolean(prop['x-references'])
if (!isEnum && !isFk) continue
interesting.push({ key, label: humanize(key), isFk })
}
return interesting
}, [detailQuery.data?.schemaJson])
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') {
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') {
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
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)
}
},
},
)
}
const activeMutation =
edit.kind === 'create' ? createMut : edit.kind === 'edit' ? updateMut : null
const serverError = serverErrorMessage(activeMutation?.error)
const totalRecords = recordsResult.data?.length ?? 0
const scope = detailQuery.data?.scope
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). */}
<div className="grid grid-cols-1 lg:grid-cols-[220px_1fr] gap-4 lg:gap-6 mt-2">
{detailQuery.data && (
<EditorInfoPanel
detail={detailQuery.data}
recordCount={totalRecords}
actions={{
onTimeTravel: () => setTimeTravelOpen((v) => !v),
timeTravelActive: Boolean(timeTravelAt),
onAoi: () => setAoiOpen(true),
aoiActive: Boolean(aoi),
// Открываем ExportModal вместо immediate triggera — пользователь
// выбирает формат/scope/columns/encoding/delimiter.
onExport: () => setExportOpen(true),
exportPending: bulkExportMut.isPending,
}}
/>
)}
<div className="min-w-0 space-y-4">
{/* WorkflowBanner — status (live/review/draft) above tab bar per handoff Screen 2. */}
{detailQuery.data && (
<WorkflowBanner detail={detailQuery.data} pendingCount={pendingByKey.size} />
)}
{/* Main editor panel — tab bar + toolbar + tab content в bordered card
* per redesign prototype. Visual containment отделяет editor от
* page chrome. */}
<div className="rounded-lg border border-line bg-surface overflow-hidden">
{/* 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,
}),
replace: true,
})
}
counts={{
records: totalRecords,
relations: (detailQuery.data ? extractOutgoingFkCount(detailQuery.data.schemaJson) : 0),
fields: Object.keys(detailQuery.data?.schemaJson?.properties ?? {}).length,
}}
actions={
canMutate ? (
<>
<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 && (
<Alert variant="error" title={t('error.failed')}>
{String(recordsResult.error)}
</Alert>
)}
{recordsResult.data && recordsResult.data.length === 0 && (
<EmptyState title={t('dict.empty')} />
)}
{recordsResult.data && recordsResult.data.length > 0 && (
<>
<div className="flex flex-wrap items-center gap-3">
<div className="flex-1 min-w-[280px] max-w-md">
<SearchInput
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('dict.filter.searchPlaceholder')}
aria-label={t('dict.filter.searchPlaceholder')}
/>
</div>
<div className="flex items-center gap-1.5" role="group" aria-label={t('dict.filter.scopeLabel')}>
{SCOPE_ORDER.map((s) => {
const active = scopeFilter.has(s)
return (
<button
key={s}
type="button"
onClick={() => toggleScope(s)}
aria-pressed={active}
className={[
'text-cap inline-flex items-center gap-1.5 px-2.5 py-1 rounded-sm border transition-colors',
active
? 'border-accent bg-accent/8 text-accent'
: 'border-line bg-white text-ink-2 hover:border-ink/40',
].join(' ')}
>
<span
className={`inline-block size-2 rounded-full ${SCOPE_DOT[s]}`}
aria-hidden="true"
/>
{t(`dict.list.section.${s}`)}
</button>
)
})}
</div>
{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>
)}
</div>
{filteredRecords.length === 0 ? (
<EmptyState title={t('dict.filter.noMatches')} />
) : (
<>
{canMutate && selection.size > 0 && (
<BulkSelectionToolbar
count={selection.size}
totalVisible={filteredCount}
onBulkClose={openBulkClose}
onClear={clearSelection}
/>
)}
<Panel>
<Table>
<TableHead>
<TableRow>
{canMutate && (
<TableHeaderCell>
<Checkbox
aria-label={t('dict.bulk.selectAll')}
checked={selectAllChecked}
onChange={toggleSelectAll}
/>
</TableHeaderCell>
)}
<TableHeaderCell>{t('dict.col.businessKey')}</TableHeaderCell>
<TableHeaderCell hideBelow="md">name / code</TableHeaderCell>
{extraColumns.map((c) => (
<TableHeaderCell key={c.key} hideBelow="lg">{c.label}</TableHeaderCell>
))}
<TableHeaderCell hideBelow="sm">{t('dict.col.scope')}</TableHeaderCell>
<TableHeaderCell hideBelow="lg">{t('dict.col.validFrom')}</TableHeaderCell>
<TableHeaderCell align="right">{t('dict.col.actions')}</TableHeaderCell>
</TableRow>
</TableHead>
<TableBody>
{visibleRecords.map((r, idx) => (
<TableRow
key={r.id}
data-selected={selection.has(r.businessKey)}
className={rowCursor === idx ? 'bg-accent-bg/40 outline outline-1 outline-accent/30' : undefined}
>
{canMutate && (
<TableCell>
<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}
</div>
</TableCell>
<TableCell hideBelow="md">
{recordDisplayName(
r.data,
detailQuery.data?.defaultLocale ?? 'ru-RU',
)}
</TableCell>
{extraColumns.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>
)
})}
<TableCell hideBelow="sm">
<Badge variant="info">{r.dataScope}</Badge>
</TableCell>
<TableCell hideBelow="lg">
<span className="text-cell text-mute">
{new Date(r.validFrom).toLocaleDateString()}
</span>
</TableCell>
<TableCell align="right">
<div className="flex items-center justify-end gap-1">
<IconButton
label={t('history.title')}
variant="default"
icon={<ClockCounterClockwiseIcon weight="regular" />}
onClick={() => setHistoryKey(r.businessKey)}
/>
{canMutate && (
<>
<IconButton
label={t('dict.action.edit')}
variant="default"
icon={<PencilSimpleIcon weight="regular" />}
onClick={() => setEdit({ kind: 'edit', record: r })}
/>
<IconButton
label={t('dict.action.close')}
variant="danger"
icon={<XCircleIcon weight="regular" />}
onClick={() => setEdit({ kind: 'close-confirm', record: r })}
/>
</>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Panel>
{totalPages > 1 && (
<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))}
/>
)}
</>
)}
</>
)}
</>
)}
{/* === 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'
? t('drawer.captionEdit', { dict: name })
: 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}
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 && (
<Alert variant="error" title={t('error.failed')}>
{String(rawRecordQuery.error)}
</Alert>
)}
{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 = «создаём новую версию с этого момента,
// старая закрывается». validFrom оставляем пустым — handleSubmit
// подставит nowIsoLocal() at submit-time (review #5: иначе stale
// если user долго держит drawer открытым).
// validTo пустой = бессрочно (или до следующей правки).
validFrom: '',
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>
<TextInput
label={t('dict.confirmClose.reason')}
placeholder={t('dict.confirmClose.reasonPlaceholder')}
value={closeReason}
onChange={(e) => setCloseReason(e.target.value)}
/>
{serverErrorMessage(closeMut.error) && (
<Alert variant="error">{serverErrorMessage(closeMut.error)}</Alert>
)}
<div className="flex justify-end gap-2 pt-2 border-t border-line">
<Button
type="button"
variant="ghost"
disabled={closeMut.isPending}
onClick={() => setEdit({ kind: 'closed' })}
>
{t('form.cancel')}
</Button>
<Button
type="button"
variant="danger"
loading={closeMut.isPending}
onClick={handleClose}
>
{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)}
/>
)}
<RecordHistoryDrawer
open={Boolean(historyKey)}
onClose={() => setHistoryKey(undefined)}
dictionaryName={name}
businessKey={historyKey}
/>
<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={bulkCloseMut.isPending}
serverError={serverErrorMessage(bulkCloseMut.error)}
result={bulkResult}
/>
</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
}
function BulkCloseDialog({
count,
reason,
onReasonChange,
onSubmit,
onClose,
isPending,
serverError,
result,
}: 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>
<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} />}
>
{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: 'История' }) },
]
return (
<div className="flex items-center gap-0 border-b border-line w-full">
{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',
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>
)
})}
{actions && <div className="ml-auto flex items-center gap-2 pl-2">{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}
className="absolute top-2 right-2 z-10 px-2.5 py-1 rounded-sm border border-line bg-surface text-cap hover:border-accent transition-colors"
>
{copied ? t('json.copied', { defaultValue: 'Скопировано' }) : t('json.copy', { defaultValue: 'Копировать' })}
</button>
<pre className="rounded-lg border border-line bg-surface-2 p-4 overflow-x-auto text-[12px] font-mono text-ink 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'аются.
*/
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 { data, isLoading, error } = useAudit({ dictionaryName: dictName, size: 50 })
// 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 (
<Alert variant="error" title={t('error.failed')}>
{String(error)}
</Alert>
)
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-mono text-cell text-ink-2">
{r.userId ?? 'anonymous'}
</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 — placeholder (Stage 3.x followup, требует backend changelog endpoint). */
function HistoryTabContent({ detail }: { detail?: import('@/api/client').DictionaryDetail }) {
const { t } = useTranslation()
if (!detail) return null
// Placeholder version timeline пока backend не выкатил
// `/api/v1/dictionaries/{name}/versions` endpoint. Single row для текущей
// schema version (HEAD). Когда backend появится — рендерим full timeline.
const propsCount = Object.keys(detail.schemaJson.properties ?? {}).length
const updatedAt = new Date(detail.updatedAt)
return (
<div className="space-y-2">
<div className="rounded-lg border border-line bg-surface p-4">
<div className="flex items-start gap-3">
{/* HEAD dot marker — filled circle */}
<div className="shrink-0 size-3 rounded-full bg-ink mt-1.5" aria-hidden />
<div className="flex-1 min-w-0">
<div className="flex items-baseline gap-2 flex-wrap">
<span className="text-mono text-title-md text-ink font-semibold">
v{detail.schemaVersion}
</span>
<span className="text-cap font-semibold tracking-wider px-1.5 py-0.5 rounded-sm bg-ink text-on-accent uppercase">
HEAD
</span>
<span className="text-mono text-cell text-mute tabular-nums ml-auto">
+{propsCount} ~0 0
</span>
</div>
<div className="text-body text-ink mt-1">
{detail.description ?? t('history.placeholder.title', {
defaultValue: 'Текущая версия схемы',
})}
</div>
<div className="text-cell text-mute mt-0.5 tabular-nums">
{updatedAt.toLocaleString(undefined, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})}
</div>
</div>
</div>
</div>
{/* Backend-pending notice */}
<div className="rounded-lg border border-dashed border-line-2 bg-surface-2/30 p-4 text-cap text-mute tracking-wider uppercase text-center">
{t('history.pending', {
defaultValue:
'Полный timeline — backend `/api/v1/dictionaries/{name}/changelog` pending',
})}
</div>
</div>
)
}