Files
mdm-ordinis/ordinis-admin-ui/src/routes/dictionaries.$name.tsx
T
Zimin A.N. 234184645f feat(records): scheduled records empty-state hint
Fixes UX bug: пользователь создаёт запись с validFrom на будущую дату
(e.g. 18.05), открывает catalog 15.05 — пустая таблица, никаких хинтов
о том что запись существует.

Backend (read-api):
- New endpoint GET /api/v1/{dict}/records/scheduled-summary
- Returns {count, nearestValidFrom} для записей с validFrom > now AND
  validTo > now в текущем scope view
- Single-aggregate JPA query (COUNT + MIN), respect scope filter same way
  as listActive — INTERNAL scheduled не учитывается для PUBLIC consumer'а

Frontend:
- New useScheduledSummary hook + ScheduledRecordsSummary type
- Empty-state в dictionaries.$name now shows: "Запланировано N, ближайшая
  на DD.MM [Перейти к этой дате]" с CTA который set'ает time-travel на
  nearestValidFrom
- i18n keys для ru/en plural forms

Связан с MR !219 (TimeTravelPicker 30-day future window) — теперь slider
позволяет дойти до даты записи, и empty-state hint позволяет узнать что
запись существует.
2026-05-15 20:17:07 +03:00

2566 lines
105 KiB
TypeScript
Raw Blame History

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