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