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 = new Set(['PUBLIC', 'INTERNAL', 'RESTRICTED']) const validateSearch = (raw: Record): 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 = 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(() => 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() 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({ 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(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(undefined) // Multi-select state. Set — стабильное id'шник. При изменении // records data (mutation success / refetch / filter change) — selection // не очищаем автоматически, но выкидываем те ключи которых больше нет // в видимом фильтре (избегаем "невидимых" выбранных строк). const [selection, setSelection] = useState>(new Set()) const [bulkOpen, setBulkOpen] = useState(false) const [bulkReason, setBulkReason] = useState('') const [bulkResult, setBulkResult] = useState(null) // Search & scope filter — URL-derived. Empty/missing = все scope активны. const search = urlSearch.q ?? '' const scopeFilter = useMemo>( () => 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() 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() 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 (
{scope && ( {/* === end 3-col grid === */} { 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' && ( 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 && ( )} {detailQuery.data && edit.kind === 'edit' && rawRecordQuery.error && ( {String(rawRecordQuery.error)} )} {detailQuery.data && edit.kind === 'edit' && rawRecordQuery.data && ( setEdit({ kind: 'closed' })} formId="record-form" layout="sections" searchFilter={drawerSearch} onlyFilled={drawerOnlyFilled} onCounterChange={(visible, total) => setDrawerCount({ visible, total })} onDirtyChange={setRecordDirtyCount} /> )} { setEdit({ kind: 'closed' }) setCloseReason('') }} title={t('dict.confirmClose.title')} maxWidth="max-w-md" > {edit.kind === 'close-confirm' && (

{t('dict.confirmClose.body', { key: edit.record.businessKey })}

setCloseReason(e.target.value)} /> {serverErrorMessage(closeMut.error) && ( {serverErrorMessage(closeMut.error)} )}
)}
{detailQuery.data && ( setSchemaEditOpen(false)} onSuccess={() => setSchemaEditOpen(false)} /> )} setHistoryKey(undefined)} dictionaryName={name} businessKey={historyKey} /> setCascadeKey(undefined)} onSuccess={() => { setCascadeKey(undefined) recordsResult.refetch() }} dictionaryName={name} businessKey={cascadeKey} /> 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 && ( 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 && ( setExportOpen(false)} detail={detailQuery.data} totalCount={totalRecords} filteredRecords={filteredRecords} selection={selection} onExportCsv={(keys) => bulkExportMut.mutate(keys)} exportPending={bulkExportMut.isPending} /> )} { setBulkOpen(false) setBulkResult(null) setBulkReason('') }} title={t('dict.bulk.confirmTitle')} maxWidth="max-w-lg" > { setBulkOpen(false) setBulkResult(null) setBulkReason('') }} isPending={bulkCloseMut.isPending} serverError={serverErrorMessage(bulkCloseMut.error)} result={bulkResult} />
) } type BulkSelectionToolbarProps = { count: number totalVisible: number onBulkClose: () => void onClear: () => void } /** * BulkSelectionToolbar — sticky bar появляется когда selection.size > 0. * *

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 (

{t('dict.bulk.selectedCount', { count, total: totalVisible })}
) } 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 (
{t('dict.page.range', { from, to, total: totalCount })}
{t('dict.page.of', { cur: page + 1, total: totalPages })}
) } 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 (
{result.closed.length > 0 && (
{t('dict.bulk.resultClosed', { count: result.closed.length })}
)} {result.skipped.length > 0 && (
{t('dict.bulk.resultSkipped', { count: result.skipped.length })}
)} {result.errors.length > 0 && (
{t('dict.bulk.resultErrors', { count: result.errors.length })}
)}
{(result.skipped.length > 0 || result.errors.length > 0) && (
    {result.skipped.map((s) => (
  • · {s.businessKey} — {s.reason}
  • ))} {result.errors.map((e) => (
  • ! {e.businessKey} — {e.reason}
  • ))}
)}
) } return (

{t('dict.bulk.confirmBody', { count })}

onReasonChange(e.target.value)} /> {serverError && {serverError}}
) } /** * 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() 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> /** 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 (
{tabs.map((tab) => { const isActive = active === tab.id const count = counts?.[tab.id] return ( ) })} {actions &&
{actions}
}
) } /** 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 (
Schema без properties.
) } // 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 ( fk {' '} {target} ) } if (Array.isArray(prop.enum) && prop.enum.length > 0) { return enum } if (prop.type === 'array' && prop.items) { const itemT = typeof prop.items === 'object' ? prop.items.type ?? '—' : '—' return {itemT}[] } if (prop.format === 'date' || prop.format === 'date-time') { return {prop.format} } return {prop.type ?? '—'} } const checkOrDash = (v: unknown) => v ? ( ) : ( ) return (
{entries.map(([key, prop], idx) => ( {/* INDEX: backend ещё не выводит явный флаг. Используем эвристику: * x-unique → автоматически индекс; x-references → FK index; иначе —. */} ))}
# {`Поле`} {`Тип`} Required Unique I18n Index {`Описание`}
{String(idx + 1).padStart(2, '0')} {key} {renderType(prop)} {checkOrDash(required.has(key))} {checkOrDash(prop['x-unique'])} {checkOrDash(prop['x-localized'])} {checkOrDash(prop['x-unique'] || Boolean(prop['x-references']))} {prop.description ?? ''}
) } /** * 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 (
        
      
) } /** * Lightweight JSON syntax highlight via regex. Returns HTML с inline * span'ами окрашенными tokens — works для display-only (Monaco не нужен). * *

Каждый escape перед взаимодействием: source already JSON.stringify'd * (safe), но regex применяется к HTML так что & < > > сначала escape'аются. */ function highlightJson(json: string): string { const escape = (s: string) => s.replace(/&/g, '&').replace(//g, '>') 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 `${match}` }, ) } /** * Events tab — embedded last 20 audit entries filtered by dictName. * Stage 3.4 polish: handoff требовал inline list вместо redirect к /audit. * *

Не показывает 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 if (error) return ( {String(error)} ) const rows = data?.content ?? [] if (rows.length === 0) return ( ) /** 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 (

{/* Filter chips per prototype line 07-editor-events */}
{FILTER_CHIPS.filter((c) => c.id === 'all' || c.count > 0).map((c) => { const active = filter === c.id return ( ) })} {t('events.viewAll.short', { defaultValue: 'Полный лог →' })}
{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 ( ) })}
{t('audit.col.time', { defaultValue: 'Время' })} {t('audit.col.action', { defaultValue: 'Тип' })} {t('audit.col.status', { defaultValue: 'Статус' })} {t('audit.col.user', { defaultValue: 'Куда · Кто' })} {t('audit.col.businessKey', { defaultValue: 'Сообщение' })}
{time.toLocaleString(undefined, { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', })} {cat} {status} {r.userId ?? 'anonymous'} {r.businessKey ? ( {r.businessKey} {' · '} {r.action} ) : ( {r.action} )}
) } /** 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 (
{/* HEAD dot marker — filled circle */}
v{detail.schemaVersion} HEAD +{propsCount} ~0 −0
{detail.description ?? t('history.placeholder.title', { defaultValue: 'Текущая версия схемы', })}
{updatedAt.toLocaleString(undefined, { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', })}
{/* Backend-pending notice */}
{t('history.pending', { defaultValue: 'Полный timeline — backend `/api/v1/dictionaries/{name}/changelog` pending', })}
) }