Files
mdm-ordinis/ordinis-admin-ui/src/routes/dictionaries.$name.tsx
T
Zimin A.N. f0392af0e3 feat(editor): Stage 3.4 polish — Events embedded + JSON syntax highlight
Per handoff Stage 3.4 polish list (Records/Relations/Fields/JSON/Events/
History tabs).

Events tab — раньше был redirect к /audit?dict=name. Теперь embedded:
- useAudit({dictionaryName, size: 20}) → last 20 entries
- 4-col compact table: time / action / businessKey / user
- Empty state когда нет событий
- Bottom link → /audit с filter для full diff view

JSON tab — раньше был plain <pre>. Теперь:
- Regex-based syntax highlight (keys=accent, strings=green, numbers=warn,
  booleans=pink, null=mute italic)
- Copy-to-clipboard button с visual feedback
- Bundle cost: 0 (нет Monaco). Если потребуется EDIT — Monaco lazy
  через React.lazy в Stage 3.x followup.

Fields tab — пока read-only table остаётся. Inline edit требует backend
PATCH /dictionaries/{name}/schema endpoint; deferred.

History tab — placeholder remains. Требует backend changelog endpoint;
deferred.

TS strict + 116 tests pass.
2026-05-11 15:28:29 +03:00

1599 lines
60 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useState } from 'react'
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import axios from 'axios'
import {
Alert,
Badge,
Button,
Checkbox,
EmptyState,
IconButton,
LoadingBlock,
Modal,
PageHeader,
Panel,
SearchInput,
Table,
TableBody,
TableCell,
TableHead,
TableHeaderCell,
TableRow,
TextInput,
} from '@/ui'
import { PlusIcon, PencilSimpleIcon, XCircleIcon, GearIcon, ClockCounterClockwiseIcon, MapTrifoldIcon, TrashIcon, CheckCircleIcon, WarningIcon, XIcon, DownloadSimpleIcon, 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 type { BulkCloseResponse, CreateRecordRequest, DataScope, FlattenedRecord } from '@/api/client'
import { SchemaDrivenForm } from '@/components/form/SchemaDrivenForm'
import { DictionaryEditorDialog } from '@/components/schema/DictionaryEditorDialog'
import { DictionaryDependentsPanel } from '@/components/lineage/DictionaryDependentsPanel'
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 { AoiPickerDialog, type AoiResult } from '@/components/geo/AoiPickerDialog'
import { TimeTravelPicker } from '@/components/timetravel/TimeTravelPicker'
import { nowIsoLocal } from '@/lib/dates'
import { recordDisplayName } from '@/lib/locales'
import { SCOPE_BORDER_TOP, SCOPE_DOT, SCOPE_ORDER } from '@/lib/scope-style'
/**
* URL search params для filter state — share-friendly, persist между F5.
* - q: search query (free text)
* - scopes: csv subset of PUBLIC/INTERNAL/RESTRICTED. Empty/missing = все.
* - bbox: AOI как CSV "west,south,east,north". Polygon AOI в URL не
* сериализуем — слишком длинно (50+ точек), оставляем в local state.
* Если bbox задан — initial AOI установится в bbox-режим при mount'е.
*/
type DictSearch = {
q?: string
scopes?: string
bbox?: string
/** Time-travel: ISO datetime "просмотр на момент X". Если задан — backend
* findActiveAt(at) возвращает active version на этот момент вместо now(). */
at?: string
/** Active editor tab (Stage 3.4 handoff design):
* - records (default): table + filters + edit modal
* - relations: dependents graph (incoming FK refs + outgoing schema FKs)
* - fields: schema properties grid
* - json: raw schema JSON viewer
* - events: audit log filtered к этому dict
* - history: timeline записей changes
* Backward-compat: старый ?view=hub аутенитично remap'нется в ?tab=relations. */
tab?: 'records' | 'relations' | 'fields' | 'json' | 'events' | 'history'
}
const SCOPE_VALUES: ReadonlySet<DataScope> = new Set(['PUBLIC', 'INTERNAL', 'RESTRICTED'])
const validateSearch = (raw: Record<string, unknown>): DictSearch => {
const out: DictSearch = {}
if (typeof raw.q === 'string' && raw.q.length > 0) out.q = raw.q
if (typeof raw.scopes === 'string') {
const valid = raw.scopes
.split(',')
.map((s) => s.trim().toUpperCase())
.filter((s): s is DataScope => SCOPE_VALUES.has(s as DataScope))
if (valid.length > 0) out.scopes = valid.join(',')
}
if (typeof raw.bbox === 'string' && /^-?\d+(\.\d+)?(,-?\d+(\.\d+)?){3}$/.test(raw.bbox)) {
out.bbox = raw.bbox
}
// ISO datetime check: 2026-05-08T12:00:00Z или 2026-05-08T12:00:00+03:00.
if (typeof raw.at === 'string' && !isNaN(Date.parse(raw.at))) {
out.at = raw.at
}
// Tab param + backward-compat для старого ?view=hub.
const TABS: ReadonlySet<DictSearch['tab']> = new Set([
'records', 'relations', 'fields', 'json', 'events', 'history',
])
if (typeof raw.tab === 'string' && TABS.has(raw.tab as DictSearch['tab'])) {
out.tab = raw.tab as DictSearch['tab']
} else if (raw.view === 'hub') {
out.tab = 'relations'
}
return out
}
export const Route = createFileRoute('/dictionaries/$name')({
component: DictionaryDetail,
validateSearch,
})
type EditState =
| { kind: 'closed' }
| { kind: 'create' }
| { kind: 'edit'; record: FlattenedRecord }
| { kind: 'close-confirm'; record: FlattenedRecord }
function DictionaryDetail() {
const { name } = Route.useParams()
const urlSearch = Route.useSearch()
const navigate = useNavigate({ from: Route.fullPath })
const { t } = useTranslation()
const detailQuery = useDictionaryDetail(name)
const canMutate = useCanMutate()
// AOI: bbox из URL → bbox-AOI rendered initially; polygon AOI живёт
// только в local state (не сериализуем в URL — слишком длинно).
// setAoi записывает в URL bbox только для bbox-mode (полигон не жмём в URL).
const [aoi, setAoi] = useState<AoiResult | null>(() => bboxToAoi(urlSearch.bbox))
// Time-travel state — keep ISO string in URL так чтобы user мог share link
// "посмотри dict на 2025-12-31T00:00:00Z". Empty/undefined = now().
const timeTravelAt: string | undefined = urlSearch.at
const setTimeTravelAt = (next: string | undefined) => {
void navigate({
search: (prev) => ({ ...prev, at: next || undefined }),
replace: true,
})
}
const filter: RecordsFilter | undefined =
aoi || timeTravelAt
? {
...(aoi?.kind === 'bbox' ? { bbox: aoi.bboxCsv } : {}),
...(aoi?.kind === 'polygon' ? { polygon: aoi.geojson } : {}),
...(timeTravelAt ? { at: timeTravelAt } : {}),
}
: undefined
const recordsResult = useRecords(name, 'PUBLIC,INTERNAL,RESTRICTED', filter)
/**
* Approval Workflow v2 Phase 4: pending drafts на этот dict — для бейджа
* "На review" в каждой строке records table. Polled 30s. Bypass'нем если
* dict не approval-required (нет смысла грузить).
*/
// Гость (anonymous) — drafts polling требует JWT (401 каждые 30s в console).
// Disable если не авторизован.
const pendingDraftsQuery = useDictPendingDrafts(
canMutate && detailQuery.data?.approvalRequired ? name : undefined,
)
const pendingByKey = useMemo(() => {
const set = new Set<string>()
for (const d of pendingDraftsQuery.data ?? []) set.add(d.businessKey)
return set
}, [pendingDraftsQuery.data])
const createMut = useCreateRecord(name)
const updateMut = useUpdateRecord(name)
// Per-form idempotency key. Stable между ререндерами для одной сессии edit
// modal'а: двойной submit (Enter + click) не создаст 2 record'а — backend
// deduplicate'ит по тому же ключу. Reset на onSuccess.
const [recordIdempotencyKey, resetRecordIdempotencyKey] = useFormIdempotencyKey()
const closeMut = useCloseRecord(name)
const bulkCloseMut = useBulkCloseRecords(name)
const bulkExportMut = useBulkExportRecords(name)
const [edit, setEdit] = useState<EditState>({ kind: 'closed' })
const [closeReason, setCloseReason] = useState('')
// Dirty-field count surfaced by SchemaDrivenForm → shown в RecordDrawer footer.
const [recordDirtyCount, setRecordDirtyCount] = useState(0)
const [schemaEditOpen, setSchemaEditOpen] = useState(false)
const [historyKey, setHistoryKey] = useState<string | undefined>(undefined)
const [aoiOpen, setAoiOpen] = useState(false)
const [timeTravelOpen, setTimeTravelOpen] = useState(false)
/** Phase 3 dict-relationships-v2: cascade dialog state. Открывается из 409
* на close, или явным action из record menu. */
const [cascadeKey, setCascadeKey] = useState<string | undefined>(undefined)
// Multi-select state. Set<businessKey> — стабильное id'шник. При изменении
// records data (mutation success / refetch / filter change) — selection
// не очищаем автоматически, но выкидываем те ключи которых больше нет
// в видимом фильтре (избегаем "невидимых" выбранных строк).
const [selection, setSelection] = useState<Set<string>>(new Set())
const [bulkOpen, setBulkOpen] = useState(false)
const [bulkReason, setBulkReason] = useState('')
const [bulkResult, setBulkResult] = useState<BulkCloseResponse | null>(null)
// Search & scope filter — URL-derived. Empty/missing = все scope активны.
const search = urlSearch.q ?? ''
const scopeFilter = useMemo<Set<DataScope>>(
() => new Set(urlSearch.scopes ? (urlSearch.scopes.split(',') as DataScope[]) : []),
[urlSearch.scopes],
)
const setSearch = (next: string) => {
void navigate({
search: (prev) => ({ ...prev, q: next.length > 0 ? next : undefined }),
replace: true,
})
}
const toggleScope = (scope: DataScope) => {
const next = new Set(scopeFilter)
if (next.has(scope)) next.delete(scope)
else next.add(scope)
void navigate({
search: (prev) => ({
...prev,
scopes: next.size > 0 ? Array.from(next).join(',') : undefined,
}),
replace: true,
})
}
const handleAoiApply = (result: AoiResult) => {
setAoi(result)
// bbox в URL для share; polygon оставляем в memory.
void navigate({
search: (prev) => ({
...prev,
bbox: result.kind === 'bbox' ? result.bboxCsv : undefined,
}),
replace: true,
})
}
const handleAoiClear = () => {
setAoi(null)
void navigate({
search: (prev) => ({ ...prev, bbox: undefined }),
replace: true,
})
}
const handleClearFilters = () => {
void navigate({
search: (prev) => ({ ...prev, q: undefined, scopes: undefined }),
replace: true,
})
}
// Уникальные validFrom timestamps для timeline marks. Используем raw
// recordsResult (не filtered) чтобы видеть все версии независимо от scope/q
// фильтров.
const recordTimestamps = useMemo(() => {
const set = new Set<number>()
for (const r of recordsResult.data ?? []) {
const ts = new Date(r.validFrom).getTime()
if (!Number.isNaN(ts)) set.add(ts)
}
return Array.from(set).sort((a, b) => a - b)
}, [recordsResult.data])
const filteredRecords = useMemo(() => {
const raw = recordsResult.data ?? []
if (raw.length === 0) return raw
const q = search.trim().toLowerCase()
const scopes = scopeFilter
return raw.filter((r) => {
if (scopes.size > 0 && !scopes.has(r.dataScope)) return false
if (q.length === 0) return true
// Search по businessKey + JSON.stringify(data) — простой,
// покрывает локализованные поля, code, описания. Stringify
// O(n) в memory, для 100 записей мгновенно.
if (r.businessKey.toLowerCase().includes(q)) return true
const haystack = JSON.stringify(r.data).toLowerCase()
return haystack.includes(q)
})
}, [recordsResult.data, search, scopeFilter])
const filtersActive = search.length > 0 || scopeFilter.size > 0
const filteredCount = filteredRecords.length
// Client-side pagination — типичный словарь до 100 записей, server-side
// пейджинг overkill. Page 0-indexed. PAGE_SIZE = 50 — достаточно чтобы
// увидеть все основные dictionaries без листания.
const PAGE_SIZE = 50
const [page, setPage] = useState(0)
const totalPages = Math.max(1, Math.ceil(filteredCount / PAGE_SIZE))
// При изменении filter — на page 0 (иначе page может оказаться вне диапазона)
useEffect(() => {
setPage(0)
}, [search, scopeFilter, aoi])
const visibleRecords = useMemo(
() => filteredRecords.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE),
[filteredRecords, page],
)
// Каноничный список видимых ключей — для select-all и для очистки selection
// от ключей которые больше не видны (filter изменился, запись закрылась).
// С client-side pagination "видимые" = только текущая страница, чтобы
// select-all чекбокс трогал ровно то что юзер видит на экране.
const visibleKeys = useMemo(
() => visibleRecords.map((r) => r.businessKey),
[visibleRecords],
)
// Когда видимый набор сократился — выкидываем из selection невидимые ключи.
// Иначе bulk close может пытаться закрыть запись которую юзер уже не видит,
// что нарушает "WYSIWYG" принцип multi-select toolbar'а.
useEffect(() => {
setSelection((prev) => {
if (prev.size === 0) return prev
const visibleSet = new Set(visibleKeys)
const next = new Set<string>()
for (const k of prev) if (visibleSet.has(k)) next.add(k)
return next.size === prev.size ? prev : next
})
}, [visibleKeys])
const allVisibleSelected =
visibleKeys.length > 0 && visibleKeys.every((k) => selection.has(k))
const someVisibleSelected =
!allVisibleSelected && visibleKeys.some((k) => selection.has(k))
// Stage 3.9: Radix Checkbox принимает {@code checked="indeterminate"} как
// tri-state значение declaratively. Старый useEffect+ref hack (для нативного
// input.indeterminate JS-only prop) больше не нужен.
const selectAllChecked: boolean | 'indeterminate' = someVisibleSelected
? 'indeterminate'
: allVisibleSelected
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)
}
const handleBulkExport = () => {
if (selection.size === 0) return
bulkExportMut.mutate(Array.from(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(),
}
if (edit.kind === 'create') {
createMut.mutate(
{ payload: submitReq, idempotencyKey: recordIdempotencyKey },
{
onSuccess: () => {
setEdit({ kind: 'closed' })
resetRecordIdempotencyKey()
},
},
)
return
}
if (edit.kind === 'edit') {
updateMut.mutate(
{
businessKey: edit.record.businessKey,
payload: submitReq,
idempotencyKey: recordIdempotencyKey,
},
{
onSuccess: () => {
setEdit({ kind: 'closed' })
resetRecordIdempotencyKey()
},
},
)
}
}
const handleClose = () => {
if (edit.kind !== 'close-confirm') return
const targetKey = edit.record.businessKey
closeMut.mutate(
{ businessKey: targetKey, reason: closeReason || undefined },
{
onSuccess: () => {
setEdit({ kind: 'closed' })
setCloseReason('')
},
onError: (err: unknown) => {
// Phase 3 dict-relationships-v2: 409 с cascade-related code
// открывает CascadeConfirmDialog. Иначе error inline в close-confirm.
const status =
(err as { response?: { status?: number } })?.response?.status
const code = (err as { response?: { data?: { code?: string } } })
?.response?.data?.code
if (
status === 409 &&
(code === 'x_references_blocked_by_dependents' ||
code === 'x_references_cascade_required')
) {
// Reset closeMut.error до открытия cascade dialog'а — иначе stale
// 409 mesg может проскочить в открытом cascade UI пока user
// ещё не сделал retry (review #18).
closeMut.reset()
setEdit({ kind: 'closed' })
setCloseReason('')
setCascadeKey(targetKey)
}
},
},
)
}
const activeMutation =
edit.kind === 'create' ? createMut : edit.kind === 'edit' ? updateMut : null
const serverError = serverErrorMessage(activeMutation?.error)
const totalRecords = recordsResult.data?.length ?? 0
const scope = detailQuery.data?.scope
return (
<div className="space-y-6">
{scope && (
<div
className={`-mt-6 -mx-4 sm:-mx-6 lg:-mx-8 mb-2 h-1 border-t-4 ${SCOPE_BORDER_TOP[scope]}`}
aria-hidden="true"
/>
)}
<PageHeader
breadcrumb={
<div className="flex items-center gap-3 text-body">
<Link to="/dictionaries" className="text-ink-2 hover:text-accent">
{t('nav.dictionaries')}
</Link>
{scope && (
<span className="flex items-center gap-1.5 text-ink-2">
<span className="text-mute/70">·</span>
<span
className={`inline-block size-2 rounded-full ${SCOPE_DOT[scope]}`}
aria-hidden="true"
/>
<span className="font-medium">{t(`dict.list.section.${scope}`)}</span>
</span>
)}
</div>
}
title={detailQuery.data?.displayName ?? name}
description={
detailQuery.data?.description
? `${detailQuery.data.description} · ${totalRecords} ${t('dict.list.records')}`
: `${totalRecords} ${t('dict.list.records')}`
}
actions={
// flex-wrap: при узком viewport кнопки переносятся на новую row
// вместо вмятия в 2 строки внутри одной кнопки. whitespace-nowrap на
// каждой Button — label всегда single line. Combination: buttons
// expand to fit text, и весь toolbar wraps below header при узком
// экране, а не давит лейблы.
<div className="flex flex-wrap items-center gap-2">
<Button
variant="secondary"
leftIcon={<MapTrifoldIcon weight="bold" size={16} />}
disabled={!detailQuery.data}
onClick={() => setAoiOpen(true)}
className="whitespace-nowrap"
>
{t('aoi.button')}
</Button>
<Button
variant="secondary"
leftIcon={<ClockCounterClockwiseIcon weight="bold" size={16} />}
disabled={!detailQuery.data}
onClick={() => setTimeTravelOpen((v) => !v)}
aria-pressed={Boolean(timeTravelAt)}
className="whitespace-nowrap"
>
{t('timeTravel.button')}
</Button>
{canMutate && (
<>
<Button
variant="secondary"
leftIcon={<GearIcon weight="bold" size={16} />}
disabled={!detailQuery.data}
onClick={() => setSchemaEditOpen(true)}
className="whitespace-nowrap"
>
{t('schema.action.editSchema')}
</Button>
<Button
variant="primary"
leftIcon={<PlusIcon weight="bold" size={16} />}
disabled={!detailQuery.data}
onClick={() => setEdit({ kind: 'create' })}
className="whitespace-nowrap"
>
{t('dict.action.create')}
</Button>
</>
)}
</div>
}
/>
{/* Editor tabs per handoff Stage 3.4:
Records | Relations | Fields | JSON | Events | History.
URL-synced через ?tab=. Default — records (records table + filters). */}
{detailQuery.data && (
<EditorTabBar
active={urlSearch.tab ?? 'records'}
onChange={(tab) =>
void navigate({
search: (prev) => ({
...prev,
tab: tab === 'records' ? undefined : tab,
}),
replace: true,
})
}
/>
)}
{/* 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 — записи version timeline (стуб, Stage 3.x). */}
{urlSearch.tab === 'history' && (
<HistoryTabContent />
)}
{/* DictionaryDependentsPanel — отображается ТОЛЬКО на Records tab.
Phase 1 dict-relationships-v2 reverse FK card. Hide-on-empty. */}
{(!urlSearch.tab || urlSearch.tab === 'records') && (
<DictionaryDependentsPanel dictionaryName={name} />
)}
{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 picker — раскрывается по клику на toolbar button.
ISO datetime в URL ?at=… — share-friendly. Active state показывает
ambient banner справа от input'а. Phase v1 stretch. */}
{timeTravelOpen && (
<TimeTravelPicker
value={timeTravelAt}
onChange={(iso) => setTimeTravelAt(iso)}
onClear={() => setTimeTravelAt(undefined)}
onClose={() => setTimeTravelOpen(false)}
marks={recordTimestamps}
/>
)}
{/* Active state — ambient banner если ?at= задан, даже если picker закрыт. */}
{timeTravelAt && !timeTravelOpen && (
<div className="flex items-center gap-3 px-3 py-2 rounded-sm border border-warn bg-warn-bg text-body">
<ClockCounterClockwiseIcon weight="bold" size={16} className="text-warn shrink-0" />
<span className=" text-mono text-ink">
{t('timeTravel.viewing', {
date: new Date(timeTravelAt).toLocaleString(),
})}
</span>
<button
type="button"
className="text-cell text-accent hover:underline ml-auto"
onClick={() => setTimeTravelAt(undefined)}
>
{t('timeTravel.clear')}
</button>
</div>
)}
{recordsResult.isLoading && <LoadingBlock size="md" label={t('loading')} />}
{recordsResult.error && (
<Alert variant="error" title={t('error.failed')}>
{String(recordsResult.error)}
</Alert>
)}
{recordsResult.data && recordsResult.data.length === 0 && (
<EmptyState title={t('dict.empty')} />
)}
{recordsResult.data && recordsResult.data.length > 0 && (
<>
<div className="flex flex-wrap items-center gap-3">
<div className="flex-1 min-w-[280px] max-w-md">
<SearchInput
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('dict.filter.searchPlaceholder')}
aria-label={t('dict.filter.searchPlaceholder')}
/>
</div>
<div className="flex items-center gap-1.5" role="group" aria-label={t('dict.filter.scopeLabel')}>
{SCOPE_ORDER.map((s) => {
const active = scopeFilter.has(s)
return (
<button
key={s}
type="button"
onClick={() => toggleScope(s)}
aria-pressed={active}
className={[
'text-cap inline-flex items-center gap-1.5 px-2.5 py-1 rounded-sm border transition-colors',
active
? 'border-accent bg-accent/8 text-accent'
: 'border-line bg-white text-ink-2 hover:border-ink/40',
].join(' ')}
>
<span
className={`inline-block size-2 rounded-full ${SCOPE_DOT[s]}`}
aria-hidden="true"
/>
{t(`dict.list.section.${s}`)}
</button>
)
})}
</div>
{filtersActive && (
<div className="flex items-center gap-2 text-cell text-ink-2">
<span>
{t('dict.filter.matched', { count: filteredCount, total: totalRecords })}
</span>
<button
type="button"
onClick={handleClearFilters}
className="text-accent hover:underline"
>
{t('dict.filter.clear')}
</button>
</div>
)}
</div>
{filteredRecords.length === 0 ? (
<EmptyState title={t('dict.filter.noMatches')} />
) : (
<>
{canMutate && selection.size > 0 && (
<BulkSelectionToolbar
count={selection.size}
totalVisible={filteredCount}
onBulkClose={openBulkClose}
onBulkExport={handleBulkExport}
onClear={clearSelection}
isExporting={bulkExportMut.isPending}
/>
)}
<Panel>
<Table>
<TableHead>
<TableRow>
{canMutate && (
<TableHeaderCell>
<Checkbox
aria-label={t('dict.bulk.selectAll')}
checked={selectAllChecked}
onChange={toggleSelectAll}
/>
</TableHeaderCell>
)}
<TableHeaderCell>{t('dict.col.businessKey')}</TableHeaderCell>
<TableHeaderCell>name / code</TableHeaderCell>
{extraColumns.map((c) => (
<TableHeaderCell key={c.key}>{c.label}</TableHeaderCell>
))}
<TableHeaderCell>{t('dict.col.scope')}</TableHeaderCell>
<TableHeaderCell>{t('dict.col.validFrom')}</TableHeaderCell>
<TableHeaderCell align="right">{t('dict.col.actions')}</TableHeaderCell>
</TableRow>
</TableHead>
<TableBody>
{visibleRecords.map((r) => (
<TableRow key={r.id} data-selected={selection.has(r.businessKey)}>
{canMutate && (
<TableCell>
<Checkbox
aria-label={`${t('dict.bulk.selectRow')} ${r.businessKey}`}
checked={selection.has(r.businessKey)}
onChange={() => toggleSelect(r.businessKey)}
/>
</TableCell>
)}
<TableCell>
<div className="flex items-center gap-1.5">
<span className="text-mono">{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>
{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}>
<span className="text-cell text-mute/70"></span>
</TableCell>
)
}
const text = String(v)
return (
<TableCell key={c.key}>
<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>
<Badge variant="info">{r.dataScope}</Badge>
</TableCell>
<TableCell>
<span className="text-cell text-mute">
{new Date(r.validFrom).toLocaleDateString()}
</span>
</TableCell>
<TableCell align="right">
<div className="flex items-center justify-end gap-1">
<IconButton
label={t('history.title')}
variant="default"
icon={<ClockCounterClockwiseIcon weight="regular" />}
onClick={() => setHistoryKey(r.businessKey)}
/>
{canMutate && (
<>
<IconButton
label={t('dict.action.edit')}
variant="default"
icon={<PencilSimpleIcon weight="regular" />}
onClick={() => setEdit({ kind: 'edit', record: r })}
/>
<IconButton
label={t('dict.action.close')}
variant="danger"
icon={<XCircleIcon weight="regular" />}
onClick={() => setEdit({ kind: 'close-confirm', record: r })}
/>
</>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Panel>
{totalPages > 1 && (
<RecordsPagination
page={page}
totalPages={totalPages}
totalCount={filteredCount}
pageSize={PAGE_SIZE}
onPrev={() => setPage((p) => Math.max(0, p - 1))}
onNext={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
/>
)}
</>
)}
</>
)}
</>
)}
{/* === end records tab content === */}
<RecordDrawer
open={edit.kind === 'create' || edit.kind === 'edit'}
onClose={() => {
setEdit({ kind: 'closed' })
setRecordDirtyCount(0)
}}
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)}
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"
onDirtyChange={setRecordDirtyCount}
/>
)}
{detailQuery.data && edit.kind === 'edit' && rawRecordQuery.isLoading && (
<LoadingBlock size="md" label={t('loading')} />
)}
{detailQuery.data && edit.kind === 'edit' && rawRecordQuery.error && (
<Alert variant="error" title={t('error.failed')}>
{String(rawRecordQuery.error)}
</Alert>
)}
{detailQuery.data && edit.kind === 'edit' && rawRecordQuery.data && (
<SchemaDrivenForm
schema={detailQuery.data.schemaJson}
supportedLocales={detailQuery.data.supportedLocales}
defaultLocale={detailQuery.data.defaultLocale}
mode="edit"
isPending={Boolean(activeMutation?.isPending)}
serverError={serverError}
defaultValues={{
businessKey: rawRecordQuery.data.businessKey,
data: rawRecordQuery.data.data,
// Edit в bitemporal model = «создаём новую версию с этого момента,
// старая закрывается». validFrom оставляем пустым — handleSubmit
// подставит nowIsoLocal() at submit-time (review #5: иначе stale
// если user долго держит drawer открытым).
// validTo пустой = бессрочно (или до следующей правки).
validFrom: '',
validTo: '',
}}
onSubmit={handleSubmit}
onCancel={() => setEdit({ kind: 'closed' })}
formId="record-form"
onDirtyChange={setRecordDirtyCount}
/>
)}
</RecordDrawer>
<Modal
isOpen={edit.kind === 'close-confirm'}
onClose={() => {
setEdit({ kind: 'closed' })
setCloseReason('')
}}
title={t('dict.confirmClose.title')}
maxWidth="max-w-md"
>
{edit.kind === 'close-confirm' && (
<div className="space-y-4">
<p className="text-body text-ink">
{t('dict.confirmClose.body', { key: edit.record.businessKey })}
</p>
<TextInput
label={t('dict.confirmClose.reason')}
placeholder={t('dict.confirmClose.reasonPlaceholder')}
value={closeReason}
onChange={(e) => setCloseReason(e.target.value)}
/>
{serverErrorMessage(closeMut.error) && (
<Alert variant="error">{serverErrorMessage(closeMut.error)}</Alert>
)}
<div className="flex justify-end gap-2 pt-2 border-t border-line">
<Button
type="button"
variant="ghost"
disabled={closeMut.isPending}
onClick={() => setEdit({ kind: 'closed' })}
>
{t('form.cancel')}
</Button>
<Button
type="button"
variant="danger"
loading={closeMut.isPending}
onClick={handleClose}
>
{t('dict.action.close')}
</Button>
</div>
</div>
)}
</Modal>
{detailQuery.data && (
<DictionaryEditorDialog
open={schemaEditOpen}
mode={{ kind: 'edit', existing: detailQuery.data }}
onClose={() => setSchemaEditOpen(false)}
onSuccess={() => setSchemaEditOpen(false)}
/>
)}
<RecordHistoryDrawer
open={Boolean(historyKey)}
onClose={() => setHistoryKey(undefined)}
dictionaryName={name}
businessKey={historyKey}
/>
<CascadeConfirmDialog
open={Boolean(cascadeKey)}
onClose={() => setCascadeKey(undefined)}
onSuccess={() => {
setCascadeKey(undefined)
recordsResult.refetch()
}}
dictionaryName={name}
businessKey={cascadeKey}
/>
<AoiPickerDialog
isOpen={aoiOpen}
onClose={() => setAoiOpen(false)}
onApply={handleAoiApply}
initial={aoi?.geojson ?? null}
/>
<Modal
isOpen={bulkOpen}
onClose={() => {
setBulkOpen(false)
setBulkResult(null)
setBulkReason('')
}}
title={t('dict.bulk.confirmTitle')}
maxWidth="max-w-lg"
>
<BulkCloseDialog
count={selection.size}
reason={bulkReason}
onReasonChange={setBulkReason}
onSubmit={handleBulkClose}
onClose={() => {
setBulkOpen(false)
setBulkResult(null)
setBulkReason('')
}}
isPending={bulkCloseMut.isPending}
serverError={serverErrorMessage(bulkCloseMut.error)}
result={bulkResult}
/>
</Modal>
</div>
)
}
type BulkSelectionToolbarProps = {
count: number
totalVisible: number
onBulkClose: () => void
onBulkExport: () => void
onClear: () => void
isExporting: boolean
}
function BulkSelectionToolbar({
count,
totalVisible,
onBulkClose,
onBulkExport,
onClear,
isExporting,
}: 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="secondary"
leftIcon={<DownloadSimpleIcon weight="bold" size={14} />}
onClick={onBulkExport}
loading={isExporting}
>
{t('dict.bulk.exportAction')}
</Button>
<Button
type="button"
variant="danger"
leftIcon={<TrashIcon weight="bold" size={14} />}
onClick={onBulkClose}
>
{t('dict.bulk.closeAction')}
</Button>
</div>
</div>
)
}
type RecordsPaginationProps = {
page: number
totalPages: number
totalCount: number
pageSize: number
onPrev: () => void
onNext: () => void
}
function RecordsPagination({
page,
totalPages,
totalCount,
pageSize,
onPrev,
onNext,
}: RecordsPaginationProps) {
const { t } = useTranslation()
const from = page * pageSize + 1
const to = Math.min((page + 1) * pageSize, totalCount)
return (
<div className="flex items-center justify-between flex-wrap gap-3 px-2">
<span className="text-cell text-ink-2 tabular-nums">
{t('dict.page.range', { from, to, total: totalCount })}
</span>
<div className="flex items-center gap-3 text-body">
<Button
type="button"
variant="secondary"
leftIcon={<CaretLeftIcon weight="bold" size={14} />}
onClick={onPrev}
disabled={page <= 0}
>
{t('dict.page.prev')}
</Button>
<span className="tabular-nums text-cell text-ink-2">
{t('dict.page.of', { cur: page + 1, total: totalPages })}
</span>
<Button
type="button"
variant="secondary"
rightIcon={<CaretRightIcon weight="bold" size={14} />}
onClick={onNext}
disabled={page + 1 >= totalPages}
>
{t('dict.page.next')}
</Button>
</div>
</div>
)
}
type BulkCloseDialogProps = {
count: number
reason: string
onReasonChange: (s: string) => void
onSubmit: () => void
onClose: () => void
isPending: boolean
serverError: string | null
result: BulkCloseResponse | null
}
function BulkCloseDialog({
count,
reason,
onReasonChange,
onSubmit,
onClose,
isPending,
serverError,
result,
}: BulkCloseDialogProps) {
const { t } = useTranslation()
// После успеха — показываем сводку; вход на confirm форму прячем
if (result) {
return (
<div className="space-y-4">
<div className="space-y-2">
{result.closed.length > 0 && (
<div className="flex items-center gap-2 text-body text-soyuz-green">
<CheckCircleIcon size={18} weight="fill" />
<span>{t('dict.bulk.resultClosed', { count: result.closed.length })}</span>
</div>
)}
{result.skipped.length > 0 && (
<div className="flex items-center gap-2 text-body text-saturn">
<WarningIcon size={18} weight="fill" />
<span>{t('dict.bulk.resultSkipped', { count: result.skipped.length })}</span>
</div>
)}
{result.errors.length > 0 && (
<div className="flex items-center gap-2 text-body text-mars">
<XCircleIcon size={18} weight="fill" />
<span>{t('dict.bulk.resultErrors', { count: result.errors.length })}</span>
</div>
)}
</div>
{(result.skipped.length > 0 || result.errors.length > 0) && (
<div className="border border-line rounded p-3 max-h-48 overflow-auto bg-line/30">
<ul className="text-mono space-y-1">
{result.skipped.map((s) => (
<li key={`s-${s.businessKey}`} className="text-saturn">
· {s.businessKey} {s.reason}
</li>
))}
{result.errors.map((e) => (
<li key={`e-${e.businessKey}`} className="text-mars">
! {e.businessKey} {e.reason}
</li>
))}
</ul>
</div>
)}
<div className="flex justify-end pt-2 border-t border-line">
<Button type="button" variant="primary" onClick={onClose}>
{t('dict.bulk.dismiss')}
</Button>
</div>
</div>
)
}
return (
<div className="space-y-4">
<p className="text-body text-ink">{t('dict.bulk.confirmBody', { count })}</p>
<TextInput
label={t('dict.confirmClose.reason')}
placeholder={t('dict.confirmClose.reasonPlaceholder')}
value={reason}
onChange={(e) => onReasonChange(e.target.value)}
/>
{serverError && <Alert variant="error">{serverError}</Alert>}
<div className="flex justify-end gap-2 pt-2 border-t border-line">
<Button
type="button"
variant="ghost"
disabled={isPending}
onClick={onClose}
>
{t('form.cancel')}
</Button>
<Button
type="button"
variant="danger"
loading={isPending}
onClick={onSubmit}
leftIcon={<TrashIcon weight="bold" size={14} />}
>
{t('dict.bulk.confirmAction', { count })}
</Button>
</div>
</div>
)
}
/**
* Bootstrap initial AOI из URL bbox param. Возвращает bbox-AOI с
* прямоугольным GeoJSON для preview в picker'е. Невалидные значения
* уже отфильтрованы validateSearch — но dup'аем guard на парсинге.
*/
/** snake_case → 'Snake Case' для column header'ов. */
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,
}: {
active: EditorTab
onChange: (tab: EditorTab) => void
}) {
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
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',
isActive
? 'border-accent text-ink font-semibold'
: 'border-transparent text-ink-2 hover:text-ink',
].join(' ')}
>
{tab.label}
</button>
)
})}
</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>
)
}
return (
<div className="rounded-lg border border-line overflow-hidden">
<table className="w-full text-body">
<thead className="text-cap bg-surface-2 font-display text-mute">
<tr>
<th className="text-left px-3 py-2">name</th>
<th className="text-left px-3 py-2">type</th>
<th className="text-left px-3 py-2">required</th>
<th className="text-left px-3 py-2">unique</th>
<th className="text-left px-3 py-2">i18n</th>
<th className="text-left px-3 py-2"> FK</th>
<th className="text-left px-3 py-2">description</th>
</tr>
</thead>
<tbody>
{entries.map(([key, prop]) => (
<tr key={key} className="border-t border-line-2 hover:bg-surface-2/50">
<td className="text-cell px-3 py-1.5 font-mono">{key}</td>
<td className="px-3 py-1.5 text-ink-2">{prop.type ?? '—'}</td>
<td className="px-3 py-1.5">{required.has(key) ? '✓' : ''}</td>
<td className="px-3 py-1.5">{prop['x-unique'] ? '✓' : ''}</td>
<td className="px-3 py-1.5">{prop['x-localized'] ? '✓' : ''}</td>
<td className="text-mono px-3 py-1.5 text-accent">
{prop['x-references'] ?? ''}
</td>
<td className="px-3 py-1.5 text-ink-2 text-cell max-w-[300px] truncate">
{prop.description ?? ''}
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
/**
* JSON tab — raw schema JSON с syntax highlight + copy button.
* Stage 3.4 polish: handoff requested Monaco editor. Monaco = ~2MB bundle
* cost; для read-only display это overkill. Используем regex-based highlight
* (keys/strings/numbers/booleans), это даёт 90% UX benefit at 0 bundle cost.
* Если потребуется EDIT (Stage 3.x) — добавим Monaco lazy через React.lazy.
*/
function JsonTabContent({ detail }: { detail: { schemaJson: import('@/api/client').JsonSchema } }) {
const { t } = useTranslation()
const [copied, setCopied] = useState(false)
const json = useMemo(() => JSON.stringify(detail.schemaJson, null, 2), [detail.schemaJson])
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(json)
setCopied(true)
setTimeout(() => setCopied(false), 1500)
} catch {
// clipboard API недоступен (insecure context) — silently ignore.
}
}
return (
<div className="relative">
<button
type="button"
onClick={handleCopy}
className="absolute top-2 right-2 z-10 px-2.5 py-1 rounded-sm border border-line bg-surface text-cap hover:border-accent transition-colors"
>
{copied ? t('json.copied', { defaultValue: 'Скопировано' }) : t('json.copy', { defaultValue: 'Копировать' })}
</button>
<pre className="rounded-lg border border-line bg-surface-2 p-4 overflow-x-auto text-[12px] font-mono text-ink whitespace-pre">
<code dangerouslySetInnerHTML={{ __html: highlightJson(json) }} />
</pre>
</div>
)
}
/**
* Lightweight JSON syntax highlight via regex. Returns HTML с inline
* span'ами окрашенными tokens — works для display-only (Monaco не нужен).
*
* <p>Каждый escape перед взаимодействием: source already JSON.stringify'd
* (safe), но regex применяется к HTML так что & < > > сначала escape'аются.
*/
function highlightJson(json: string): string {
const escape = (s: string) =>
s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
return escape(json).replace(
/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(\.\d+)?([eE][+-]?\d+)?)/g,
(match) => {
let cls = 'text-warn'
if (/^"/.test(match)) {
cls = /:$/.test(match) ? 'text-accent' : 'text-green'
} else if (/true|false/.test(match)) {
cls = 'text-pink'
} else if (/null/.test(match)) {
cls = 'text-mute italic'
}
return `<span class="${cls}">${match}</span>`
},
)
}
/**
* Events tab — embedded last 20 audit entries filtered by dictName.
* Stage 3.4 polish: handoff требовал inline list вместо redirect к /audit.
*
* <p>Не показывает payloadBefore/After (expand-detail) — для полного diff
* остался deep-link к /audit?dict=name внизу. Здесь — таймстамп + action +
* businessKey + author, плотно в 4-col grid.
*/
function EventsTabContent({ dictName }: { dictName: string }) {
const { t } = useTranslation()
const { data, isLoading, error } = useAudit({ dictionaryName: dictName, size: 20 })
if (isLoading) return <LoadingBlock size="md" label={t('loading')} />
if (error)
return (
<Alert variant="error" title={t('error.failed')}>
{String(error)}
</Alert>
)
const rows = data?.content ?? []
if (rows.length === 0)
return (
<EmptyState
title={t('events.empty.title', { defaultValue: 'Событий пока нет' })}
description={t('events.empty.description', {
defaultValue: 'Любая правка записей справочника появится здесь.',
})}
/>
)
return (
<div className="space-y-3">
<div className="rounded-lg border border-line overflow-hidden">
<table className="w-full">
<thead className="bg-surface-2">
<tr>
<th className="text-cap text-mute text-left px-3 py-2">{t('audit.col.time', { defaultValue: 'time' })}</th>
<th className="text-cap text-mute text-left px-3 py-2">{t('audit.col.action', { defaultValue: 'action' })}</th>
<th className="text-cap text-mute text-left px-3 py-2">{t('audit.col.businessKey', { defaultValue: 'record' })}</th>
<th className="text-cap text-mute text-left px-3 py-2">{t('audit.col.user', { defaultValue: 'user' })}</th>
</tr>
</thead>
<tbody>
{rows.map((r) => {
const time = new Date(r.eventTime)
const variant =
r.action === 'CREATE'
? ('success' as const)
: r.action === 'CLOSE'
? ('error' as const)
: ('info' as const)
return (
<tr key={`${r.id}`} className="border-t border-line-2 hover:bg-surface-2/40">
<td className="text-cell tabular-nums px-3 py-1.5">
{time.toLocaleString(undefined, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})}
</td>
<td className="px-3 py-1.5">
<Badge variant={variant}>
{t(`audit.action.${r.action}`, r.action)}
</Badge>
</td>
<td className="text-mono px-3 py-1.5">{r.businessKey ?? '—'}</td>
<td className="text-cell px-3 py-1.5 text-ink-2">{r.userId ?? 'anonymous'}</td>
</tr>
)
})}
</tbody>
</table>
</div>
<div className="text-right">
<Link
to="/audit"
search={{ dict: dictName }}
className="inline-flex items-center gap-1 text-accent hover:underline text-body"
>
{t('events.viewAll', { defaultValue: 'Полный аудит-лог →' })}
</Link>
</div>
</div>
)
}
/** History tab — placeholder (Stage 3.x followup, требует backend changelog endpoint). */
function HistoryTabContent() {
return (
<div className="rounded-lg border border-dashed border-line bg-surface-2/30 p-8 text-center text-body text-mute">
Timeline изменений справочника скоро. Требует backend endpoint для schema versions.
</div>
)
}